This commit is contained in:
2026-11-02 17:00:28 +08:00
parent 5d0f629439
commit 9ed39b51f7
43 changed files with 7163 additions and 151 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
__pycache__/
*.py[cod]
*.egg-info/

78
INSTALLATION.md Normal file
View File

@@ -0,0 +1,78 @@
## Installation
Press the shortcut key "Win+R", type words "cmd", press "Enter", then open a terminal in Windows.
Execute the following command in a terminal to install (upgrade) Guan package to the latest version (https://pypi.org/project/guan/):
```bash
pip install --upgrade guan -i https://pypi.python.org/simple
```
or
```bash
pip install --upgrade guan
```
See the installed version of Guan package on your computer:
```bash
pip show guan
```
If you do not use this package anymore, you can uninstall it:
```bash
pip uninstall guan
```
Usage:
```python
import guan
```
Prerequisites:
```text
Python3
[Packages partially used, which have been included in Anaconda]
numpy
math
cmath
os
copy
matplotlib
re
datetime
bs4
requests
functools
sympy
collections
pickle
time
urllib
hashlib
shutil
logging
ssl
random
json
...
[Packages partially used, which need to be additionally installed when they are needed]
jieba (pip install jieba)
imageio (pip install imageio)
pdfplumber (pip install pdfplumber)
PyPDF2 (pip install PyPDF2)
pdfminer3k (pip install pdfminer3k)
pygame (pip install pygame)
akshare (pip install akshare)
pyttsx3 (pip install pyttsx3)
pydub (pip install pydub)
qrcode (pip install qrcode)
FFmpeg (http://www.ffmpeg.org/download.html)
...
```

View File

@@ -1,7 +1,7 @@
[metadata]
# replace with your username:
name = guan
version = 0.1.202
version = 0.2.0
author = guanjihuan
author_email = guanjihuan@163.com
description = An open source python package

View File

@@ -1,17 +0,0 @@
Metadata-Version: 2.4
Name: guan
Version: 0.1.202
Summary: An open source python package
Home-page: https://py.guanjihuan.com
Author: guanjihuan
Author-email: guanjihuan@163.com
Project-URL: Bug Tracker, https://py.guanjihuan.com
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file
Guan is an open-source python package developed and maintained by https://www.guanjihuan.com/about (Ji-Huan Guan, 关济寰). The primary location of this package is on website https://py.guanjihuan.com. The GitHub location of this package is on https://github.com/guanjihuan/py.guanjihuan.com.

View File

@@ -1,24 +0,0 @@
LICENSE
README.md
pyproject.toml
setup.cfg
src/guan/AI_chat.py
src/guan/Fourier_transform.py
src/guan/Green_functions.py
src/guan/Hamiltonian_of_examples.py
src/guan/__init__.py
src/guan/band_structures_and_wave_functions.py
src/guan/basic_functions.py
src/guan/data_processing.py
src/guan/decorators.py
src/guan/density_of_states.py
src/guan/figure_plotting.py
src/guan/file_reading_and_writing.py
src/guan/machine_learning.py
src/guan/others.py
src/guan/quantum_transport.py
src/guan/topological_invariant.py
src/guan.egg-info/PKG-INFO
src/guan.egg-info/SOURCES.txt
src/guan.egg-info/dependency_links.txt
src/guan.egg-info/top_level.txt

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@
guan

View File

@@ -1,64 +1,5 @@
# Module: AI_chat
# AI 对话(当前默认模型为 'hunyuan-lite',无记忆)
def chat(prompt='你好', model=1, stream=1, stream_label=0):
import requests
url = "http://api.guanjihuan.com/chat"
data = {
"prompt": prompt,
"model": model,
}
if stream == 1:
if stream_label == 1:
print('\n--- Start Chat Stream Message ---\n')
requests_response = requests.post(url, json=data, stream=True)
response = ''
if requests_response.status_code == 200:
for line in requests_response.iter_lines():
if line:
if stream == 1:
print(line.decode('utf-8'), end='', flush=True)
response += line.decode('utf-8')
print()
else:
pass
if stream == 1:
if stream_label == 1:
print('\n--- End Chat Stream Message ---\n')
return response
# 加上函数代码的 AI 对话(当前默认模型为 'hunyuan-lite',无记忆)
def chat_with_function_code(function_name, prompt='', model=1, stream=1):
import guan
function_source = guan.get_source(function_name)
if prompt == '':
response = guan.chat(prompt=function_source, model=model, stream=stream)
else:
response = guan.chat(prompt=function_source+'\n\n'+prompt, model=model, stream=stream)
return response
# 机器人自动对话(当前默认模型为 'hunyuan-lite',无记忆)
def auto_chat(prompt='你好', round=2, model=1, stream=1):
import guan
response0 = prompt
for i0 in range(round):
print(f'\n【对话第 {i0+1} 轮】\n')
print('机器人 1: ')
response1 = guan.chat(prompt=response0, model=model, stream=stream)
print('机器人 2: ')
response0 = guan.chat(prompt=response1, model=model, stream=stream)
# 机器人自动对话(引导对话)(当前默认模型为 'hunyuan-lite',无记忆)
def auto_chat_with_guide(prompt='你好', guide_message='(回答字数少于30个字,最后反问我一个问题)', round=5, model=1, stream=1):
import guan
response0 = prompt
for i0 in range(round):
print(f'\n【对话第 {i0+1} 轮】\n')
print('机器人 1: ')
response1 = guan.chat(prompt=response0+guide_message, model=model, stream=stream)
print('机器人 2: ')
response0 = guan.chat(prompt=response1+guide_message, model=model, stream=stream)
# 使用 OpenAI 框架对话(需要 API Key)
def openai_chat(prompt="你好", model="qwen-plus", temperature=0.7, system_message=None, history=[], print_show=1, load_env=1):
import os

View File

@@ -15,4 +15,5 @@ from .data_processing import *
from .AI_chat import *
from .decorators import *
from .others import *
from .deprecated import *
statistics_of_guan_package()

106
PyPI/src/guan/deprecated.py Normal file
View File

@@ -0,0 +1,106 @@
# Module: deprecated
# --- 废弃函数/版本兼容(不推荐使用,并可能在未来的版本中被移除) ---
# AI 对话(当前默认模型为 'hunyuan-lite',无记忆)
def chat(prompt='你好', model=1, stream=1, stream_label=0):
import requests
url = "http://api.guanjihuan.com/chat"
data = {
"prompt": prompt,
"model": model,
}
if stream == 1:
if stream_label == 1:
print('\n--- Start Chat Stream Message ---\n')
requests_response = requests.post(url, json=data, stream=True)
response = ''
if requests_response.status_code == 200:
for line in requests_response.iter_lines():
if line:
if stream == 1:
print(line.decode('utf-8'), end='', flush=True)
response += line.decode('utf-8')
print()
else:
pass
if stream == 1:
if stream_label == 1:
print('\n--- End Chat Stream Message ---\n')
return response
# 加上函数代码的 AI 对话(当前默认模型为 'hunyuan-lite',无记忆)
def chat_with_function_code(function_name, prompt='', model=1, stream=1):
import guan
function_source = guan.get_source(function_name)
if prompt == '':
response = guan.chat(prompt=function_source, model=model, stream=stream)
else:
response = guan.chat(prompt=function_source+'\n\n'+prompt, model=model, stream=stream)
return response
# 机器人自动对话(当前默认模型为 'hunyuan-lite',无记忆)
def auto_chat(prompt='你好', round=2, model=1, stream=1):
import guan
response0 = prompt
for i0 in range(round):
print(f'\n【对话第 {i0+1} 轮】\n')
print('机器人 1: ')
response1 = guan.chat(prompt=response0, model=model, stream=stream)
print('机器人 2: ')
response0 = guan.chat(prompt=response1, model=model, stream=stream)
# 机器人自动对话(引导对话)(当前默认模型为 'hunyuan-lite',无记忆)
def auto_chat_with_guide(prompt='你好', guide_message='(回答字数少于30个字,最后反问我一个问题)', round=5, model=1, stream=1):
import guan
response0 = prompt
for i0 in range(round):
print(f'\n【对话第 {i0+1} 轮】\n')
print('机器人 1: ')
response1 = guan.chat(prompt=response0+guide_message, model=model, stream=stream)
print('机器人 2: ')
response0 = guan.chat(prompt=response1+guide_message, model=model, stream=stream)
def make_sh_file(sh_filename='a', command_line='python a.py', cpu_num=1, task_name='task', cd_dir=0):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.make_sh_file_for_qsub().')
guan.make_sh_file_for_qsub(sh_filename=sh_filename, command_line=command_line, cpu_num=cpu_num, task_name=task_name, cd_dir=cd_dir)
def plot_without_starting_fig(plt, fig, ax, x_array, y_array, xlabel='x', ylabel='y', title='', fontsize=20, style='', y_min=None, y_max=None, linewidth=None, markersize=None, color=None, fontfamily='Times New Roman'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.plot_without_starting_fig_ax().')
guan.plot_without_starting_fig_ax(plt, fig, ax, x_array, y_array, xlabel=xlabel, ylabel=ylabel, title=title, fontsize=fontsize, style=style, y_min=y_min, y_max=y_max, linewidth=linewidth, markersize=markersize, color=color, fontfamily=fontfamily)
def draw_dots_and_lines_without_starting_fig(plt, fig, ax, coordinate_array, draw_dots=1, draw_lines=1, max_distance=1, line_style='-k', linewidth=1, dot_style='ro', markersize=3):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.draw_dots_and_lines_without_starting_fig_ax().')
guan.draw_dots_and_lines_without_starting_fig_ax(plt, fig, ax, coordinate_array, draw_dots=draw_dots, draw_lines=draw_lines, max_distance=max_distance, line_style=line_style, linewidth=linewidth, dot_style=dot_style, markersize=markersize)
def get_days_of_the_current_month(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_current_month().')
date_array = guan.get_date_array_of_the_current_month(str_or_datetime=str_or_datetime)
return date_array
def get_days_of_the_last_month(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_last_month().')
date_array = guan.get_date_array_of_the_last_month(str_or_datetime=str_or_datetime)
return date_array
def get_days_of_the_month_before_last(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_month_before_last().')
date_array = guan.get_date_array_of_the_month_before_last(str_or_datetime=str_or_datetime)
return date_array
def pdf_to_text(pdf_path):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.pdf_to_text_with_pdfminer3k().')
content = guan.pdf_to_text_with_pdfminer3k(pdf_path)
return content
def statistics_with_day_and_time(content='', filename='time_logging', file_format='.txt'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.logging_with_day_and_time().')
guan.logging_with_day_and_time(content=content, filename=filename, file_format=file_format)

View File

@@ -1283,49 +1283,3 @@ def initialize_hamiltonian_from_atom_object_list(atom_object_list):
for i0 in range(num):
hamiltonian[i0*dim+0:i0*dim+dim, i0*dim+0:i0*dim+dim] = atom_object_list[i0].energy
return hamiltonian
# --- 废弃函数/版本兼容(不推荐使用,并可能在未来的版本中被移除)deprecated ---
def make_sh_file(sh_filename='a', command_line='python a.py', cpu_num=1, task_name='task', cd_dir=0):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.make_sh_file_for_qsub().')
guan.make_sh_file_for_qsub(sh_filename=sh_filename, command_line=command_line, cpu_num=cpu_num, task_name=task_name, cd_dir=cd_dir)
def plot_without_starting_fig(plt, fig, ax, x_array, y_array, xlabel='x', ylabel='y', title='', fontsize=20, style='', y_min=None, y_max=None, linewidth=None, markersize=None, color=None, fontfamily='Times New Roman'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.plot_without_starting_fig_ax().')
guan.plot_without_starting_fig_ax(plt, fig, ax, x_array, y_array, xlabel=xlabel, ylabel=ylabel, title=title, fontsize=fontsize, style=style, y_min=y_min, y_max=y_max, linewidth=linewidth, markersize=markersize, color=color, fontfamily=fontfamily)
def draw_dots_and_lines_without_starting_fig(plt, fig, ax, coordinate_array, draw_dots=1, draw_lines=1, max_distance=1, line_style='-k', linewidth=1, dot_style='ro', markersize=3):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.draw_dots_and_lines_without_starting_fig_ax().')
guan.draw_dots_and_lines_without_starting_fig_ax(plt, fig, ax, coordinate_array, draw_dots=draw_dots, draw_lines=draw_lines, max_distance=max_distance, line_style=line_style, linewidth=linewidth, dot_style=dot_style, markersize=markersize)
def get_days_of_the_current_month(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_current_month().')
date_array = guan.get_date_array_of_the_current_month(str_or_datetime=str_or_datetime)
return date_array
def get_days_of_the_last_month(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_last_month().')
date_array = guan.get_date_array_of_the_last_month(str_or_datetime=str_or_datetime)
return date_array
def get_days_of_the_month_before_last(str_or_datetime='str'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_month_before_last().')
date_array = guan.get_date_array_of_the_month_before_last(str_or_datetime=str_or_datetime)
return date_array
def pdf_to_text(pdf_path):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.pdf_to_text_with_pdfminer3k().')
content = guan.pdf_to_text_with_pdfminer3k(pdf_path)
return content
def statistics_with_day_and_time(content='', filename='time_logging', file_format='.txt'):
import guan
print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.logging_with_day_and_time().')
guan.logging_with_day_and_time(content=content, filename=filename, file_format=file_format)

View File

@@ -6,11 +6,15 @@ The primary location of this package is on https://py.guanjihuan.com.
## Installation
```bash
pip install --upgrade guan
```
## Usage
```python
import guan
```
## Summary of API Reference
@@ -43,3 +47,22 @@ There is no official paper related to this package temporarily, but you can cite
+ Ji-Huan Guan, Wen-Kai Lou, and Kai Chang, Topological hidden phase transition in honeycomb bilayers with a high Chern number, Phys. Rev. B 110, 165303 (2024).
+ Ji-Huan Guan, Yan-Yang Zhang, Si-Si Wang, Yan Yu, Yang Xia and Shu-Shen Li, Barrier Tunneling and Loop Polarization in Hopf Semimetals. Phys. Rev. B 102, 064203 (2020).
+ Ji-Huan Guan, Yan-Yang Zhang, Wei-Er Lu, Yang Xia and Shu-Shen Li, Barrier tunneling of the loop-nodal semimetal in the hyperhoneycomb lattice. J. Phys.: Condens. Matter 30, 185402 (2018).
## Website
The website (https://py.guanjihuan.com) is generated from the source code in `PyPI/src/guan` by static parsing, so it always matches the source code. It hosts the home page, the module list, the data tables, the full API reference, the about page, the installation page and `api.json`.
Build the distribution:
```bash
python -m build --outdir dist --no-isolation PyPI
```
Build the website:
```bash
python website/build_site.py -o dist/site
```
The build also checks the generated pages for broken links and reports source files that fail to parse. Pass `--strict` to make it exit with a non-zero status when something is wrong, which is handy in CI.

3
tutorials/00_test.py Normal file
View File

@@ -0,0 +1,3 @@
# 测试代码
import guan
guan.test()

93
tutorials/README.md Normal file
View File

@@ -0,0 +1,93 @@
# Tutorials
Learning examples for the [Guan package](https://py.guanjihuan.com), extracted from the blog post
[Guan软件包学习示例](https://www.guanjihuan.com/archives/38367).
One example per file. Each file is a self-contained script that you can run directly, or copy the
functions you need into your own project.
## Installation
```bash
pip install --upgrade guan
```
```python
import guan
```
## Run the examples
```bash
python tutorials/daily_scenarios/01_ai_chat.py
python tutorials/physics_scenarios/03_band_structure.py
```
## Directory
```
tutorials/
├── 00_test.py # package self test
├── daily_scenarios/ # 一、日常场景
│ ├── 01_ai_chat.py
│ ├── 02_timing_log.py
│ ├── 03_function_timer.py
│ ├── 04_try_except.py
│ ├── 05_dump_and_load_data.py
│ ├── 06_print_array.py
│ ├── 07_matrix_in_file.py
│ ├── 08_parallel_calculation.py
│ ├── 09_loop_calculation.py
│ ├── 10_is_hermitian.py
│ └── 11_split_parameters.py
└── physics_scenarios/ # 二、物理场景
├── 01_pauli_matrices.py
├── 02_real_space_hamiltonian.py
├── 03_band_structure.py
├── 04_chern_number_and_wilson_loop.py
├── 05_density_of_states_with_green_function.py
├── 06_conductance_and_scattering_matrix.py
└── 07_wavefunction_gauge.py
```
## 一、日常场景 / Daily scenarios
| File | 说明 | Requirements |
| --- | --- | --- |
| `00_test.py` | 软件包自检 `guan.test()` | - |
| `01_ai_chat.py` | AI 对话:内置模型、OpenAI 兼容接口、LangChain、Ollama、llama.cpp、ModelScope | OpenAI 接口需要 API Key;Ollama / llama.cpp / ModelScope 需要本地模型 |
| `02_timing_log.py` | 带日期和时间的运行日志 `guan.logging_with_day_and_time` | - |
| `03_function_timer.py` | 函数计时器:`@guan.timer_decorator` 与 `guan.timer` | - |
| `04_try_except.py` | try except 简写:`@guan.try_decorator` 与 `guan.try_except` | - |
| `05_dump_and_load_data.py` | 变量写入文件并读回 | numpy |
| `06_print_array.py` | 打印数组 | numpy |
| `07_matrix_in_file.py` | 矩阵写入 Markdown / LaTeX 格式文件后查看 | numpy |
| `08_parallel_calculation.py` | 多进程并行计算 | - |
| `09_loop_calculation.py` | 循环参数计算(一、二、三个参数)与作图 | numpy, matplotlib |
| `10_is_hermitian.py` | 检查是否为厄米矩阵(相对误差 1e-5) | numpy |
| `11_split_parameters.py` | 数组分割,为并行计算准备参数 | numpy |
## 二、物理场景 / Physics scenarios
| File | 说明 | Requirements |
| --- | --- | --- |
| `01_pauli_matrices.py` | 泡利矩阵 | numpy |
| `02_real_space_hamiltonian.py` | 一维 / 二维方格 / 三维立方格的实空间哈密顿量 | numpy |
| `03_band_structure.py` | 能带图计算:一维链、方格带、石墨烯锯齿形带 | numpy, matplotlib |
| `04_chern_number_and_wilson_loop.py` | 陈数与 Wilson loop 计算 | numpy |
| `05_density_of_states_with_green_function.py` | 用格林函数计算态密度(方格与立方格,局域态密度) | numpy, matplotlib |
| `06_conductance_and_scattering_matrix.py` | 电导与散射矩阵的计算 | numpy, matplotlib |
| `07_wavefunction_gauge.py` | 波函数规范(fixed gauge)的选取 | numpy |
## 说明 / Notes
+ Some examples need an API Key, a local Ollama/llama.cpp/ModelScope model, or a specific model path.
Edit the paths in the scripts before running them.
+ `04_try_except.py` contains a deliberate `bug_code` call to demonstrate the exception handling.
+ Figures are saved to the current working directory when the plotting functions are used.
## License / 许可
The source post is published under [CC BY](https://www.guanjihuan.com), by
[Ji-Huan Guan](https://www.guanjihuan.com/about). Please cite the source when quoting:
https://www.guanjihuan.com/archives/38367

View File

@@ -0,0 +1,19 @@
# AI 对话
import guan
response, history = guan.openai_chat("你好", model="qwen-plus") # 需要 API Key
print()
response = guan.langchain_chat_without_memory(prompt="你好", model="qwen-plus") # 需要 API Key
response == guan.langchain_chat_with_memory(prompt="你好", model="qwen-plus") # 需要 API Key
print()
response = guan.ollama_chat(prompt='你好', model="qwen2.5:0.5b") # 需要运行 Ollama 和下载对应的模型
print()
llm = guan.load_llama_model(model_path="D:/models/Qwen/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf") # 需要有模型文件
response, history = guan.llama_chat(llm, '你好 /no_think')
print()
model, tokenizer = guan.load_modelscope_model(model_name="D:/models/Qwen/Qwen3-0.6B") # 需要有模型文件
response, history = guan.modelscope_chat(model, tokenizer, '你好 /no_think')

View File

@@ -0,0 +1,7 @@
# 运行时间日志
import guan
import time
guan.logging_with_day_and_time(content='start')
for i in range(3):
time.sleep(5)
guan.logging_with_day_and_time(f'end_of_{i}')

View File

@@ -0,0 +1,25 @@
# 函数计时器
import guan
@guan.timer_decorator
def test1(a, b):
import time
print(a)
time.sleep(1)
print(b)
print('Run finished.')
for _ in range(2):
test1(10, b=20)
print()
def test2(a, b):
import time
print(a)
time.sleep(1)
print(b)
print('Run finished.')
for _ in range(2):
guan.timer(test2, 100, b=200)

View File

@@ -0,0 +1,23 @@
# try except 简写
import guan
@guan.try_decorator
def test1(a, b):
print(a)
bug_code
print(b)
return 'return_message1'
result1 = test1(10, b=20)
print(result1)
print()
def test2(a, b):
print(a)
bug_code
print(b)
return 'return_message2'
result2 = guan.try_except(test2, 100, b=200)
print(result2)

View File

@@ -0,0 +1,7 @@
# 变量写入文件
import guan
import numpy as np
data = np.array([1, 2, 3])
guan.dump_data(data, filename='a')
loaded_data = guan.load_data(filename='a')
print(loaded_data)

View File

@@ -0,0 +1,4 @@
# 打印数组
import guan
a = [1, 2, 3, 'a', 'b', 'c']
guan.print_array(a)

View File

@@ -0,0 +1,6 @@
# 矩阵写入文件后查看
import guan
import numpy as np
matrix = np.random.rand(5, 5)
guan.write_matrix_in_markdown_format(matrix=matrix, filename='markdown_matrix')
guan.write_matrix_in_latex_format(matrix=matrix, filename='latex_matrix')

View File

@@ -0,0 +1,15 @@
# 并行计算
import guan
import time
import os
def run_proc(name):
start_time = time.time()
time.sleep(5)
end_time = time.time()
print ('Process id running on name %s = %s' % (name, os.getpid()), '; running time = %s' % (end_time-start_time))
return f'name_{name}'
if __name__ == '__main__':
result_array = guan.parallel_calculation_with_multiprocessing_Pool(func=run_proc, args_list=range(32), show_time=1)
print(result_array)

View File

@@ -0,0 +1,33 @@
# 循环参数计算
import guan
import numpy as np
def test_1(x):
return 2*x
x_array = np.arange(0, 5, 1)
result_array = guan.loop_calculation_with_one_parameter(test_1, x_array)
print(result_array)
guan.plot(x_array, result_array)
print()
def test_2(x, y):
return x+y
x_array = np.arange(0, 5, 1)
y_array = np.arange(0, 3, 1)
result_array = guan.loop_calculation_with_two_parameters(test_2, x_array, y_array)
print(result_array)
guan.plot_contour(x_array, y_array, result_array)
print()
def test_3(x, y, z):
return x+y+z
x_array = np.arange(0, 5, 1)
y_array = np.arange(0, 3, 1)
z_array = np.arange(0, 2, 1)
result_array = guan.loop_calculation_with_three_parameters(test_3, x_array, y_array, z_array)
print(result_array)
guan.plot_contour(y_array, z_array, result_array[:, :, 4])

View File

@@ -0,0 +1,16 @@
# 检查是否为厄米矩阵
import guan
matrix1 = [
[2, 1.00001-1j],
[1+1j, 1]
]
print(guan.is_hermitian(matrix1))
matrix2 = [
[2, 1.00002-1j],
[1+1j, 1]
]
print(guan.is_hermitian(matrix2))

View File

@@ -0,0 +1,9 @@
# 数组分割
import numpy as np
import guan
task_num = 4
parameter_array_all = np.arange(0, 17, 1)
for task_index in range(task_num):
parameter_array = guan.preprocess_for_parallel_calculations(parameter_array_all, task_num, task_index)
print(parameter_array)
print()

View File

@@ -0,0 +1,4 @@
# 泡利矩阵
import guan
sigma_x = guan.sigma_x()
print(sigma_x)

View File

@@ -0,0 +1,5 @@
# 实空间哈密顿量
import guan
print('\n', guan.hamiltonian_of_finite_size_system_along_one_direction(3), '\n')
print(guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(2, 2), '\n')
print(guan.hamiltonian_of_finite_size_system_along_three_directions_for_cubic_lattice(2, 2, 2), '\n')

View File

@@ -0,0 +1,14 @@
# 能带图计算
import guan
import numpy as np
k_array = np.linspace(-np.pi, np.pi, 100)
# one dimensional chain
hamiltonian_function = guan.one_dimensional_fourier_transform_with_k(unit_cell=0, hopping=1)
eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, hamiltonian_function)
guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None)
# square lattice ribbon
eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, guan.hamiltonian_of_square_lattice_in_quasi_one_dimension)
guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None)
# graphene ribbon
eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, guan.hamiltonian_of_graphene_with_zigzag_in_quasi_one_dimension)
guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None)

View File

@@ -0,0 +1,9 @@
# 陈数和Wilson loop计算
import guan
import numpy as np
chern_number = guan.calculate_chern_number_for_square_lattice_with_efficient_method(guan.hamiltonian_of_one_QAH_model, precision=100)
print('\nChern number=', chern_number, '\n')
wilson_loop_array = guan.calculate_wilson_loop(guan.hamiltonian_of_ssh_model)
print('Wilson loop =', wilson_loop_array)
p = np.log(wilson_loop_array)/2/np.pi/1j
print('\np =', p, '\n')

View File

@@ -0,0 +1,34 @@
# 使用格林函数计算态密度
import guan
import numpy as np
hamiltonian = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(2,2)
fermi_energy_array = np.linspace(-4, 4, 400)
total_dos_array = guan.total_density_of_states_with_fermi_energy_array(fermi_energy_array, hamiltonian, broadening=0.1)
guan.plot(fermi_energy_array, total_dos_array, xlabel='E', ylabel='Total DOS', style='-', fontfamily=None)
fermi_energy = 0
N1 = 3
N2 = 4
hamiltonian = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(N1,N2)
LDOS = guan.local_density_of_states_for_square_lattice(fermi_energy, hamiltonian, N1=N1, N2=N2)
print('square lattice:\n', LDOS, '\n')
h00 = guan.hamiltonian_of_finite_size_system_along_one_direction(N2)
h01 = np.identity(N2)
LDOS = guan.local_density_of_states_for_square_lattice_using_dyson_equation(fermi_energy, h00=h00, h01=h01, N2=N2, N1=N1)
print(LDOS, '\n\n')
LDOS2 = guan.local_density_of_states_for_square_lattice_using_dyson_equation_with_second_method(fermi_energy, h00, h01, N2, N1, internal_degree=1, broadening=0.01)
print(LDOS2, '\n\n')
guan.plot_contour(range(N1), range(N2), LDOS, fontfamily=None)
guan.plot_contour(range(N1), range(N2), LDOS2, fontfamily=None)
N1 = 3
N2 = 4
N3 = 5
hamiltonian = guan.hamiltonian_of_finite_size_system_along_three_directions_for_cubic_lattice(N1, N2, N3)
LDOS = guan.local_density_of_states_for_cubic_lattice(fermi_energy, hamiltonian, N1=N1, N2=N2, N3=N3)
print('cubic lattice:\n', LDOS, '\n')
h00 = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(N2, N3)
h01 = np.identity(N2*N3)
LDOS = guan.local_density_of_states_for_cubic_lattice_using_dyson_equation(fermi_energy, h00, h01, N3=N3, N2=N2, N1=N1)
print(LDOS)

View File

@@ -0,0 +1,12 @@
# 电导和散射矩阵的计算
import guan
import numpy as np
fermi_energy_array = np.linspace(-4, 4, 400)
h00 = guan.hamiltonian_of_finite_size_system_along_one_direction(4)
h01 = np.identity(4)
conductance_array = guan.calculate_conductance_with_fermi_energy_array(fermi_energy_array, h00, h01)
guan.plot(fermi_energy_array, conductance_array, xlabel='E', ylabel='Conductance', style='-', fontfamily=None)
fermi_energy = 0
guan.print_or_write_scattering_matrix(fermi_energy, h00, h01)

View File

@@ -0,0 +1,22 @@
# 波函数规范的选取
import numpy as np
import cmath
import guan
# Fixed gauge example 1
vector = np.array([np.sqrt(0.5), np.sqrt(0.5)])*cmath.exp(np.random.uniform(0, 1)*1j)
print('\nExample 1\n', vector)
print(np.dot(vector.transpose().conj(), vector), '\n')
vector = guan.find_vector_with_fixed_gauge_by_making_one_component_real(vector)
print(vector)
print(np.dot(vector.transpose().conj(), vector), '\n')
# Fixed gauge example 2
vector = np.array([1, 0])*cmath.exp(np.random.uniform(0, 1)*1j)
print('\nExample 2\n', vector)
print(np.dot(vector.transpose().conj(), vector), '\n')
vector = guan.find_vector_with_fixed_gauge_by_making_one_component_real(vector)
print(vector)
print(np.dot(vector.transpose().conj(), vector), '\n')

279
website/audit_site.py Normal file
View File

@@ -0,0 +1,279 @@
"""审计生成的站点:链接、HTML 结构、SEO、可访问性。
用法:
python website/audit_site.py [站点目录]
默认审计 website/dist/site。
"""
from __future__ import annotations
import html
import json
import re
import sys
from collections import Counter
from pathlib import Path
from urllib.parse import unquote, urlparse
ROOT = Path(__file__).resolve().parent.parent
SITE = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "website" / "dist" / "site"
problems: list[str] = []
notes: list[str] = []
def report(kind: str, page: str, message: str) -> None:
problems.append(f"[{kind}] {page}: {message}")
HTML_PAGES = sorted(SITE.rglob("*.html"))
all_files = {p.relative_to(SITE).as_posix() for p in SITE.rglob("*") if p.is_file()}
# ---------------------------------------------------------------------------
# 1. 链接检查
# ---------------------------------------------------------------------------
ATTR_RE = re.compile(r"""\b(href|src)\s*=\s*(["'])(.*?)\2""", re.S | re.I)
ID_RE = re.compile(r"""\bid\s*=\s*(["'])(.*?)\1""", re.S | re.I)
page_ids: dict[str, set[str]] = {}
for page in HTML_PAGES:
text = page.read_text(encoding="utf-8")
page_ids[page.relative_to(SITE).as_posix()] = {m.group(2) for m in ID_RE.finditer(text)}
broken_links = 0
external_links: Counter[str] = Counter()
for page in HTML_PAGES:
rel = page.relative_to(SITE).as_posix()
text = page.read_text(encoding="utf-8")
for match in ATTR_RE.finditer(text):
attr, raw = match.group(1).lower(), match.group(3).strip()
if not raw or raw.startswith(("javascript:", "data:", "mailto:", "tel:", "#")):
if raw.startswith("#"):
anchor = unquote(raw[1:])
if anchor and anchor not in page_ids[rel]:
report("锚点", rel, f"指向本页不存在的 id: {raw}")
continue
parsed = urlparse(raw)
if parsed.scheme in ("http", "https"):
external_links[parsed.netloc] += 1
continue
if parsed.netloc:
report("链接", rel, f"未知协议/域名: {raw}")
continue
target = unquote(parsed.path)
if not target:
continue
base = page.parent
dest = (base / target).resolve()
try:
dest_rel = dest.relative_to(SITE.resolve())
except ValueError:
report("链接", rel, f"指向站点外部: {raw}")
continue
if target.endswith("/"):
dest = dest / "index.html"
if not dest.exists():
report("链接", rel, f"目标不存在: {raw}")
broken_links += 1
elif parsed.fragment:
frag = unquote(parsed.fragment)
dest_rel = dest_rel.as_posix()
if dest_rel in page_ids and frag not in page_ids[dest_rel]:
report("锚点", rel, f"{raw} -> 目标页无 id: {frag}")
# ---------------------------------------------------------------------------
# 2. HTML 结构检查
# ---------------------------------------------------------------------------
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr"}
BLOCK = {"html", "head", "body", "div", "main", "header", "footer", "nav",
"section", "article", "aside", "ul", "ol", "li", "table", "tbody",
"thead", "tr", "td", "th", "form", "p", "h1", "h2", "h3", "h4", "h5",
"h6", "pre", "blockquote", "details", "summary", "figure", "picture",
"video", "audio", "dl", "dt", "dd", "hr", "fieldset", "option",
"select", "textarea", "button", "nav", "template", "svg", "canvas",
"iframe", "span", "label", "code", "a", "strong", "em", "b", "i",
"small", "sub", "sup", "br", "img", "table", "caption", "colgroup",
"style", "script", "noscript"}
for page in HTML_PAGES:
rel = page.relative_to(SITE).as_posix()
text = page.read_text(encoding="utf-8")
# doctype
if not text.lstrip().lower().startswith("<!doctype html"):
report("结构", rel, "缺少 <!DOCTYPE html>")
# lang 属性
m = re.search(r"<html\b([^>]*)>", text, re.I)
if not m:
report("结构", rel, "缺少 <html> 标签")
elif not re.search(r"\blang\s*=", m.group(1), re.I):
report("SEO", rel, "<html> 缺少 lang 属性")
# title
if not re.search(r"<title>.+?</title>", text, re.S | re.I):
report("SEO", rel, "缺少非空 <title>")
# meta description
md = re.search(r'<meta\s+name=["\']description["\']\s+content=["\'](.*?)["\']', text, re.S | re.I)
if not md:
report("SEO", rel, "缺少 meta description")
elif not md.group(1).strip():
report("SEO", rel, "meta description 为空")
# viewport
if not re.search(r'<meta\s+name=["\']viewport["\']', text, re.I):
report("SEO", rel, "缺少 viewport meta")
# 标题层级:h1 应唯一
h1s = re.findall(r"<h1\b", text, re.I)
if len(h1s) != 1:
report("结构", rel, f"h1 数量为 {len(h1s)}(应为 1)")
# 标题层级不跳级
levels = [int(m) for m in re.findall(r"<h([1-6])\b", text, re.I)]
for prev, cur in zip(levels, levels[1:]):
if cur > prev + 1:
report("结构", rel, f"标题层级跳跃 h{prev} -> h{cur}")
# 标签配对(简单栈式检查,忽略 void 与 script/style 内容)
stripped = re.sub(r"<(script|style)\b.*?</\1>", "", text, flags=re.S | re.I)
stack: list[str] = []
for m in re.finditer(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(/?)>", stripped):
closing, tag, selfclose = m.group(1), m.group(2).lower(), m.group(3)
if tag in VOID or selfclose:
continue
if closing:
if not stack:
report("结构", rel, f"多余的 </{tag}>")
elif stack[-1] != tag:
if tag in stack:
while stack and stack[-1] != tag:
report("结构", rel, f"标签未闭合: <{stack.pop()}>")
stack.pop()
else:
report("结构", rel, f"标签错配: </{tag}> 对应 <{stack[-1]}>")
stack.pop()
else:
stack.pop()
else:
stack.append(tag)
for leftover in reversed(stack):
report("结构", rel, f"标签未闭合: <{leftover}>")
# 重复 id
ids = [m.group(2) for m in ID_RE.finditer(text)]
dupes = [i for i, c in Counter(ids).items() if c > 1]
if dupes:
report("结构", rel, f"重复 id: {dupes[:5]}")
# 图片 alt
for m in re.finditer(r"<img\b[^>]*>", text, re.I | re.S):
if not re.search(r"\balt\s*=", m.group(0), re.I):
report("可访问性", rel, f"img 缺少 alt: {m.group(0)[:70]}")
# 链接文本
for m in re.finditer(r"<a\b([^>]*)>(.*?)</a>", text, re.S | re.I):
attrs, inner = m.group(1), m.group(2)
if 'aria-label="' in attrs or "aria-label='" in attrs:
continue
text_only = re.sub(r"<[^>]+>", "", inner).strip()
if not text_only and not re.search(r"\bdownload\b", attrs, re.I):
report("可访问性", rel, f"空链接文本: {attrs[:70]}")
# 按钮缺 type
for m in re.finditer(r"<button\b[^>]*>", text, re.I):
if not re.search(r"\btype\s*=", m.group(0), re.I):
report("结构", rel, "button 缺少 type 属性")
# 硬编码语言
if 'lang="en"' in text.lower() and "en/" in rel:
pass
# ---------------------------------------------------------------------------
# 3. 跨页面语言检查
# ---------------------------------------------------------------------------
for page in HTML_PAGES:
rel = page.relative_to(SITE).as_posix()
text = page.read_text(encoding="utf-8")
m = re.search(r"<html\b([^>]*)>", text, re.I)
lang = re.search(r'lang=["\']([^"\']+)["\']', m.group(1) if m else "")
expected = "en" if rel.startswith("en/") else "zh-CN"
if lang and lang.group(1) != expected:
report("i18n", rel, f"lang={lang.group(1)} 期望 {expected}")
# 中英文页面数量配平
zh_pages = {p.relative_to(SITE).as_posix() for p in HTML_PAGES if not p.relative_to(SITE).as_posix().startswith("en/")}
en_pages = {p.relative_to(SITE).as_posix()[3:] for p in HTML_PAGES if p.relative_to(SITE).as_posix().startswith("en/")}
if zh_pages != en_pages:
only_zh = sorted(zh_pages - en_pages)
only_en = sorted(en_pages - zh_pages)
if only_zh:
report("i18n", "-", f"只有中文的页面: {only_zh}")
if only_en:
report("i18n", "-", f"只有英文的页面: {only_en}")
# ---------------------------------------------------------------------------
# 4. 静态资源
# ---------------------------------------------------------------------------
for page in HTML_PAGES:
rel = page.relative_to(SITE).as_posix()
text = page.read_text(encoding="utf-8")
for m in re.finditer(r'<script\b[^>]*\bsrc=["\']([^"\']+)["\']', text, re.I):
src = urlparse(m.group(1)).path
p = (page.parent / unquote(src)).resolve()
if not p.exists():
report("资源", rel, f"脚本不存在: {m.group(1)}")
# 孤立的静态资源
referenced: set[str] = set()
for page in HTML_PAGES:
text = page.read_text(encoding="utf-8")
for m in ATTR_RE.finditer(text):
raw = m.group(3).strip()
if raw.startswith(("http", "mailto:", "javascript:", "data:", "#")):
continue
target = unquote(urlparse(raw).path)
if target:
referenced.add(((page.parent / target).resolve()).as_posix())
for f in SITE.rglob("*"):
if f.is_file() and f.suffix.lower() in (".css", ".js", ".jpg", ".png", ".svg", ".ico"):
if f.resolve().as_posix() not in referenced:
notes.append(f"未被引用的静态文件: {f.relative_to(SITE).as_posix()}")
# ---------------------------------------------------------------------------
# 5. api.json
# ---------------------------------------------------------------------------
api_json = SITE / "api.json"
if api_json.exists():
try:
data = json.loads(api_json.read_text(encoding="utf-8"))
notes.append(f"api.json 解析正常,顶层键: {list(data)[:8] if isinstance(data, dict) else type(data)}")
except Exception as exc:
report("api.json", "-", f"解析失败: {exc}")
# ---------------------------------------------------------------------------
# 6. 控制台输出
# ---------------------------------------------------------------------------
print("=" * 60)
print(f"审计目录: {SITE}")
print(f"页面数: {len(HTML_PAGES)} 文件数: {len(all_files)}")
print("=" * 60)
if problems:
print(f"\n发现 {len(problems)} 个问题:\n")
for p in problems:
print(" " + p)
else:
print("\n未发现问题。")
if notes:
print(f"\n提示 ({len(notes)}):")
for n in notes:
print(" - " + n)
if external_links:
print("\n外部域名引用:")
for domain, count in external_links.most_common():
print(f" {domain}: {count}")
print()
sys.exit(1 if problems else 0)

1277
website/build_site.py Normal file

File diff suppressed because it is too large Load Diff

1572
website/extract.py Normal file

File diff suppressed because it is too large Load Diff

294
website/i18n.py Normal file
View File

@@ -0,0 +1,294 @@
"""站点界面的中英文文案。
只有「网站框架」的部分需要翻译:导航、按钮、页面标题、页脚统计等等。
文档正文(README.md、INSTALLATION.md)、源码里的说明与注释、教程脚本
都保持原样,不在这里翻译——它们是文件内容,不是界面。
站点是静态的,所以不做运行时切换,而是构建时生成两套页面:
* 中文在站点根目录(index.html)
* 英文在 en/ 子目录(en/index.html)
两套页面里的文件引用都相对于各自所在的目录,所以每个语言目录都是自洽的。
新增文案时,zh 与 en 两边都要写;缺哪一边,构建时会直接报错,
免得网站上线后有一半界面还是另一种语言。
"""
from __future__ import annotations
# 站点支持的语言,按顺序构建
LANGS: tuple[str, ...] = ("zh", "en")
# 默认语言(首页用的语言,也是没有指定语言时的回退)
DEFAULT_LANG = "zh"
# 每种语言的页面放在站点根目录下的哪个子目录
LANG_DIRS: dict[str, str] = {
"zh": "",
"en": "en",
}
# <html lang="..."> 与 hreflang 用的取值
HTML_LANG: dict[str, str] = {
"zh": "zh-CN",
"en": "en",
}
# 语言在界面上的名字(切换按钮上显示的)
LANG_NAMES: dict[str, str] = {
"zh": "中",
"en": "En",
}
# 语言切换按钮上显示的完整语言名。
# 用完整名字而不是「中 / En」,是因为无障碍规则要求:
# 按钮上看得见的文字必须包含在它给读屏软件的无障碍名称里。
# 「En」配「切换语言」对不上,写成「English」配「切换到 English 界面」就没问题。
LANG_NAMES_FULL: dict[str, str] = {
"zh": "中文",
"en": "English",
}
# --------------------------------------------------------------------------
# 界面文案
# --------------------------------------------------------------------------
STRINGS: dict[str, dict[str, str]] = {
"zh": {
# 顶栏与页脚
"skip": "跳到主要内容",
"theme_toggle": "切换深浅色主题",
"menu_open": "打开菜单",
"menu_close": "关闭菜单",
"to_top": "回到顶部",
"foot_stats": "{version} · {modules} 个模块 · {functions} 个函数 · {lines} 行源码",
"switch_language": "切换语言",
"switch_to_lang": "切换到 {lang} 界面",
# 导航
"nav_home": "首页",
"nav_installation": "安装",
"nav_api": "API 参考",
"nav_tutorials": "教程",
"nav_about": "关于",
# 页面标题与描述
"page_home": "{name} 官方网站",
"page_installation": "安装",
"page_api": "API 参考",
"page_tutorials": "教程",
"page_about": "关于",
"desc_installation": "{name} 的安装与使用说明",
"desc_api": "{name} 的完整 API 参考",
"desc_tutorials": "{name} 的教程与示例代码",
"desc_about": "关于 {name}",
# 首页
"hero_eyebrow": "Python package",
"hero_version": "当前版本",
"hero_python": "支持 Python {version}",
"copy": "复制",
"stat_modules": "模块",
"stat_functions": "函数",
"stat_lines": "行源码",
"section_modules": "模块一览",
"all_api": "全部 API →",
"section_package": "包信息",
"field_display_name": "展示名",
"field_pypi_name": "PyPI 名称",
"field_import_name": "导入名",
"field_version": "版本",
"field_python": "Python",
"field_homepage": "主页",
# API 参考
"api_modules_title": "模块",
"api_functions_title": "函数",
"api_classes_title": "类",
"param_name": "参数",
"param_type": "类型",
"param_default": "默认值",
"param_required": "必填",
"badge_deprecated": "已弃用",
"badge_not_exported": "未导出",
"parse_error": "解析失败",
"view_source": "查看源码",
"calls": "调用了:",
"base_classes": "基类:",
"module_meta": "{lines} 行 · {count} 个函数",
# 关于 / 安装 / 教程
"toc_title": "目录",
"tut_groups_title": "分组",
"tut_root_title": "开始使用",
"tut_root_subtitle": "Getting started",
"file_not_found": "未找到 {path}。",
"download_script": "下载脚本",
"view_original": "查看原文",
"lines": "{count} 行",
"functions_count": "{count} 个函数",
},
"en": {
# 顶栏与页脚
"skip": "Skip to main content",
"theme_toggle": "Toggle light and dark theme",
"menu_open": "Open menu",
"menu_close": "Close menu",
"to_top": "Back to top",
"foot_stats": (
"{version} · {modules} modules · {functions} functions · {lines} lines of code"
),
"switch_language": "Switch language",
"switch_to_lang": "Switch to {lang} interface",
# 导航
"nav_home": "Home",
"nav_installation": "Install",
"nav_api": "API Reference",
"nav_tutorials": "Tutorials",
"nav_about": "About",
# 页面标题与描述
"page_home": "{name} official site",
"page_installation": "Installation",
"page_api": "API Reference",
"page_tutorials": "Tutorials",
"page_about": "About",
"desc_installation": "How to install and use {name}",
"desc_api": "The complete API reference of {name}",
"desc_tutorials": "Tutorials and example code for {name}",
"desc_about": "About {name}",
# 首页
"hero_eyebrow": "Python package",
"hero_version": "Current version",
"hero_python": "Supports Python {version}",
"copy": "Copy",
"stat_modules": "Modules",
"stat_functions": "Functions",
"stat_lines": "Lines of code",
"section_modules": "Modules",
"all_api": "All API →",
"section_package": "Package info",
"field_display_name": "Display name",
"field_pypi_name": "PyPI name",
"field_import_name": "Import name",
"field_version": "Version",
"field_python": "Python",
"field_homepage": "Homepage",
# API 参考
"api_modules_title": "Modules",
"api_functions_title": "Functions",
"api_classes_title": "Classes",
"param_name": "Parameter",
"param_type": "Type",
"param_default": "Default",
"param_required": "Required",
"badge_deprecated": "Deprecated",
"badge_not_exported": "Not exported",
"parse_error": "Parse error",
"view_source": "View source",
"calls": "Calls: ",
"base_classes": "Base classes: ",
"module_meta": "{lines} lines · {count} functions",
# 关于 / 安装 / 教程
"toc_title": "Contents",
"tut_groups_title": "Groups",
"tut_root_title": "Getting started",
"tut_root_subtitle": "Start here",
"file_not_found": "{path} not found.",
"download_script": "Download script",
"view_original": "View original",
"lines": "{count} lines",
"functions_count": "{count} functions",
},
}
# --------------------------------------------------------------------------
# 脚本里用到的文案
# --------------------------------------------------------------------------
# site.js 在运行时才生成一些按钮(复制按钮、筛选框……),
# 这些文字没法在构建时写进 HTML,只能由页面把文案传给脚本。
JS_STRINGS: dict[str, dict[str, str]] = {
"zh": {
"copied": "已复制",
"copy": "复制",
"copy_code": "复制代码",
"copy_anchor": "复制本函数的链接",
"theme_to_light": "切换到浅色主题",
"theme_to_dark": "切换到深色主题",
"menu_open": "打开菜单",
"menu_close": "关闭菜单",
"filter_placeholder": "在本页筛选函数…",
"filter_label": "筛选函数",
"filter_clear": "清除",
"filter_count": "显示 {shown} / {total} 个函数",
},
"en": {
"copied": "Copied",
"copy": "Copy",
"copy_code": "Copy code",
"copy_anchor": "Copy link to this function",
"theme_to_light": "Switch to light theme",
"theme_to_dark": "Switch to dark theme",
"menu_open": "Open menu",
"menu_close": "Close menu",
"filter_placeholder": "Filter functions on this page…",
"filter_label": "Filter functions",
"filter_clear": "Clear",
"filter_count": "Showing {shown} / {total} functions",
},
}
class Translator:
"""按语言取文案的小工具。
t("nav_api") 取文案;t("lines", count=3) 取带占位符的文案。
找不到的键原样返回,方便一眼看出漏了哪一条。
"""
def __init__(self, lang: str) -> None:
if lang not in STRINGS:
raise ValueError(f"没有这种语言:{lang}")
self.lang = lang
self._strings = STRINGS[lang]
self._js = JS_STRINGS[lang]
def __call__(self, key: str, **kwargs: object) -> str:
text = self._strings.get(key)
if text is None:
# 漏写时退回默认语言,再退回键名本身
text = STRINGS[DEFAULT_LANG].get(key, key)
if not kwargs:
return text
return text.format(**kwargs)
def js(self, key: str, **kwargs: object) -> str:
text = self._js.get(key)
if text is None:
text = JS_STRINGS[DEFAULT_LANG].get(key, key)
if not kwargs:
return text
return text.format(**kwargs)
def js_all(self) -> dict[str, str]:
"""整份脚本文案,供页面传给 site.js。"""
return dict(self._js)
def translator(lang: str) -> Translator:
return Translator(lang)
def missing_keys() -> list[str]:
"""检查各语言的文案是否齐全,返回缺失的键。
构建前跑一次,缺哪边就补哪边——比上线后才发现强。
"""
problems: list[str] = []
reference = set(STRINGS[DEFAULT_LANG])
for lang, strings in STRINGS.items():
for key in sorted(reference - set(strings)):
problems.append(f"{lang} 缺少文案 {key}")
for key in sorted(set(strings) - reference):
problems.append(f"{lang} 多出文案 {key}")
js_reference = set(JS_STRINGS[DEFAULT_LANG])
for lang, strings in JS_STRINGS.items():
for key in sorted(js_reference - set(strings)):
problems.append(f"{lang} 缺少脚本文案 {key}")
for key in sorted(set(strings) - js_reference):
problems.append(f"{lang} 多出脚本文案 {key}")
return problems

535
website/md_render.py Normal file
View File

@@ -0,0 +1,535 @@
"""把 Markdown 转成 HTML。
构建后端只依赖标准库,所以这里自带一个精简的 Markdown 渲染器,
覆盖 README 里常见与可能出现的写法:
* 标题(# ~ ######)与 setext 标题(=== / ---)
* 段落
* 无序列表(+ - *)与有序列表(1. 1)),支持嵌套与任务列表
* 围栏代码块(```)与缩进代码块(4 空格)
* 行内代码、**粗体**、*斜体*、~~删除线~~
* [文字](链接)、![图片](链接)、引用式链接 [文字][ref] 与 [ref]: 地址
* 裸露的 URL、邮箱、<url> 自动链接
* 引用块(>)
* 分隔线(---)
* 表格(GitHub 风格)
"""
from __future__ import annotations
import html
import re
# --------------------------------------------------------------------------
# 行内
# --------------------------------------------------------------------------
CODE_SPAN_RE = re.compile(r"`([^`]+)`")
# 链接地址里允许出现一层成对的括号,例如维基百科风格的链接
_URL = r"[^()\s]*(?:\([^()]*\)[^()\s]*)*"
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)")
LINK_RE = re.compile(r"\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)")
REF_LINK_RE = re.compile(r"\[([^\]]*)\]\[([^\]]*)\]")
AUTOLINK_RE = re.compile(r"https?://[^\s<>\"'),,。]+")
ANGLE_AUTOLINK_RE = re.compile(r"<((?:https?|mailto):[^>\s]+)>")
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
ITALIC_RE = re.compile(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])")
STRIKE_RE = re.compile(r"~~(.+?)~~")
HR_RE = re.compile(r"^(\s*)([-*_])(\s*\2){2,}\s*$")
# 行内链接引用定义: [ref]: https://...
REF_DEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+\"[^\"]*\")?\s*$")
# 列表项:允许只有一个符号(空列表项),但符号与文字之间必须有空格
LIST_ITEM_RE = re.compile(
r"^(?P<indent>[ \t]*)(?P<bullet>[-*+]|\d+[.)])(?:[ \t]+(?P<text>.*))?$"
)
_PLACEHOLDER = "\x00{}\x00"
def _stash(store: list[str], html_text: str) -> str:
"""把已渲染好的片段存起来,返回占位符。"""
store.append(html_text)
return _PLACEHOLDER.format(len(store) - 1)
def _restore(text: str, store: list[str]) -> str:
for index, value in enumerate(store):
text = text.replace(_PLACEHOLDER.format(index), value)
return text
def render_inline(text: str, references: dict[str, str] | None = None) -> str:
"""渲染行内标记。"""
store: list[str] = []
references = references or {}
# 行内代码:内容原样转义,不参与后续的强调处理
def code_sub(match: re.Match) -> str:
return _stash(store, f"<code>{html.escape(match.group(1))}</code>")
text = CODE_SPAN_RE.sub(code_sub, text)
# 图片:![alt](url)
def image_sub(match: re.Match) -> str:
alt = html.escape(match.group(1))
url = html.escape(match.group(2), quote=True)
return _stash(store, f'<img src="{url}" alt="{alt}">')
text = IMAGE_RE.sub(image_sub, text)
# 引用式链接 [文字][ref](ref 为空时用文字本身作 ref)
def ref_sub(match: re.Match) -> str:
label = match.group(1)
ref = (match.group(2) or label).strip().lower()
url = references.get(ref)
if not url:
return match.group(0)
return _stash(
store,
f'<a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>',
)
text = REF_LINK_RE.sub(ref_sub, text)
# 链接:[文字](地址)
def link_sub(match: re.Match) -> str:
label = html.escape(match.group(1))
url = html.escape(match.group(2), quote=True)
return _stash(store, f'<a href="{url}">{label}</a>')
text = LINK_RE.sub(link_sub, text)
# <url> 自动链接
def angle_sub(match: re.Match) -> str:
url = match.group(1)
return _stash(
store,
f'<a href="{html.escape(url, quote=True)}">{html.escape(url)}</a>',
)
text = ANGLE_AUTOLINK_RE.sub(angle_sub, text)
# 裸露的 URL 与邮箱
def url_sub(match: re.Match) -> str:
url = match.group(0)
trailing = ""
# 句末标点不属于链接;成对括号之外的右括号也不属于
while url and url[-1] in ".,;:!?":
trailing = url[-1] + trailing
url = url[:-1]
while url.endswith(")") and url.count(")") > url.count("("):
trailing = ")" + trailing
url = url[:-1]
return _stash(
store,
f'<a href="{html.escape(url, quote=True)}">{html.escape(url)}</a>{trailing}',
)
text = AUTOLINK_RE.sub(url_sub, text)
def mail_sub(match: re.Match) -> str:
address = match.group(0)
return _stash(
store,
f'<a href="mailto:{html.escape(address, quote=True)}">'
f"{html.escape(address)}</a>",
)
text = EMAIL_RE.sub(mail_sub, text)
# 剩下的普通文本转义
text = html.escape(text)
# 强调
text = STRIKE_RE.sub(r"<del>\1</del>", text)
text = BOLD_RE.sub(r"<strong>\1</strong>", text)
text = ITALIC_RE.sub(r"<em>\1</em>", text)
return _restore(text, store)
# --------------------------------------------------------------------------
# 块级
# --------------------------------------------------------------------------
def _is_table_separator(line: str) -> bool:
stripped = line.strip()
if "|" not in stripped or "-" not in stripped:
return False
return all(
part.strip().replace(":", "").replace("-", "") == ""
for part in stripped.strip("|").split("|")
)
def _split_table_row(line: str) -> list[str]:
stripped = line.strip()
if stripped.startswith("|"):
stripped = stripped[1:]
if stripped.endswith("|"):
stripped = stripped[:-1]
return [cell.strip() for cell in stripped.split("|")]
def _indent_of(line: str) -> int:
return len(line) - len(line.lstrip())
def _starts_block(lines: list[str], index: int) -> bool:
"""判断该行是否是一个新块的开头(用于终止段落)。"""
line = lines[index]
stripped = line.strip()
if not stripped:
return True
if stripped.startswith("```") or stripped.startswith("~~~"):
return True
if re.match(r"^#{1,6}\s", stripped):
return True
if stripped.startswith(">"):
return True
if re.match(r"^[-*+]\s", stripped):
return True
if re.match(r"^\d+[.)]\s", stripped):
return True
if line.startswith(" ") and line.strip():
return True
if HR_RE.match(line):
return True
if _is_setext_underline(stripped):
return True
if index + 1 < len(lines) and "|" in stripped and _is_table_separator(lines[index + 1]):
return True
return False
def _is_setext_underline(stripped: str) -> bool:
"""是否是 setext 标题的下划线(=== 或 ---)。"""
if not stripped:
return False
if set(stripped) == {"="}:
return True
# 单个 "-" 可能是空列表项,因此要求至少两个
return set(stripped) == {"-"} and len(stripped) >= 2
def _render_table(
lines: list[str], index: int, references: dict[str, str]
) -> tuple[str, int]:
"""渲染 GitHub 风格表格,返回 (html, 下一行下标)。"""
header = _split_table_row(lines[index])
index += 2 # 跳过表头与分隔行
rows: list[list[str]] = []
while index < len(lines) and "|" in lines[index] and lines[index].strip():
rows.append(_split_table_row(lines[index]))
index += 1
out = ['<div class="table-wrap"><table class="md-table">']
out.append("<thead><tr>")
for cell in header:
out.append(f"<th>{render_inline(cell, references)}</th>")
out.append("</tr></thead><tbody>")
width = len(header)
for row in rows:
out.append("<tr>")
# 补齐缺失的单元格,避免表格错位
cells = list(row[:width]) + [""] * max(0, width - len(row))
for cell in cells:
out.append(f"<td>{render_inline(cell, references)}</td>")
out.append("</tr>")
out.append("</tbody></table></div>")
return "\n".join(out), index
def _dedent(lines: list[str]) -> list[str]:
"""去掉一组行的公共缩进。"""
indents = [_indent_of(line) for line in lines if line.strip()]
if not indents:
return list(lines)
common = min(indents)
return [line[common:] if line.strip() else "" for line in lines]
def _render_list(
lines: list[str],
index: int,
toc: list[dict],
used: set[str],
references: dict[str, str],
) -> tuple[str, int]:
"""渲染有序 / 无序列表(支持嵌套列表与任务列表),返回 (html, 下一行下标)。"""
match = LIST_ITEM_RE.match(lines[index])
if not match:
return "", index
ordered = bool(re.match(r"^\d+[.)]$", match.group("bullet")))
base_indent = _indent_of(lines[index])
items: list[list[str]] = [] # 每个列表项的内容行
current: list[str] = []
i = index
def is_same_kind(line: str) -> bool:
item = LIST_ITEM_RE.match(line)
if not item:
return False
if _indent_of(line) > base_indent:
return False
return bool(re.match(r"^\d+[.)]$", item.group("bullet"))) == ordered
while i < len(lines):
line = lines[i]
if not line.strip():
# 空行:其后若仍是本列表则继续,否则列表结束
look = i + 1
while look < len(lines) and not lines[look].strip():
look += 1
if look < len(lines):
item = LIST_ITEM_RE.match(lines[look])
if item and _indent_of(lines[look]) <= base_indent and is_same_kind(lines[look]):
current.append("")
i = look
continue
if item and _indent_of(lines[look]) > base_indent:
current.append("")
i = look
continue
break
item = LIST_ITEM_RE.match(line)
if is_same_kind(line):
if current:
items.append(current)
current = [(item.group("text") or "") if item else ""]
i += 1
continue
if HR_RE.match(line) and _indent_of(line) <= base_indent:
# 分隔线结束当前列表
break
if item and _indent_of(line) > base_indent:
# 嵌套列表:整块收进当前项
if current:
current.append(line)
i += 1
continue
if item and _indent_of(line) <= base_indent:
# 同级的另一种列表(例如无序列表之后紧跟有序列表):另起一个列表
break
if current:
current.append(line.strip()) # 懒续行
i += 1
continue
break
if current:
items.append(current)
tag = "ol" if ordered else "ul"
out = [f"<{tag}>"]
for item_lines in items:
content = _dedent(item_lines)
text = content[0] if content else ""
# 嵌套内容按它自己的缩进单独去掉一层,
# 否则更深一层的列表会被当成缩进代码块
inner = _dedent(content[1:]) if len(content) > 1 else []
checkbox = ""
task = re.match(r"^\[([ xX])\]\s+(.*)$", text)
if task:
checked = " checked" if task.group(1).lower() == "x" else ""
checkbox = f'<input type="checkbox" disabled{checked}> '
text = task.group(2)
body = checkbox + render_inline(text, references)
if any(line.strip() for line in inner):
nested = _render("\n".join(inner), toc, used, references)
body += nested
out.append(f"<li>{body}</li>")
out.append(f"</{tag}>")
return "\n".join(out), i
def render(markdown: str) -> str:
"""把 Markdown 全文转成 HTML。"""
html_text, _ = render_document(markdown)
return html_text
def _slug(text: str, used: set[str]) -> str:
"""由标题文字生成唯一锚点。"""
base = re.sub(r"[^\w一-鿿]+", "-", text.strip().lower()).strip("-")
if not base:
base = "section"
slug = base
counter = 2
while slug in used:
slug = f"{base}-{counter}"
counter += 1
used.add(slug)
return slug
def render_document(markdown: str) -> tuple[str, list[dict]]:
"""渲染 Markdown,同时返回目录(标题级别、标题文字、锚点)。
目录用于在宽屏上给正文旁边配一个可跳转的侧栏。
"""
toc: list[dict] = []
used: set[str] = set()
references = _collect_references(markdown)
html_text = _render(markdown, toc, used, references)
return html_text, toc
def _collect_references(markdown: str) -> dict[str, str]:
"""收集全文里的链接引用定义 [ref]: url。"""
references: dict[str, str] = {}
in_fence = False
for line in markdown.replace("\r\n", "\n").split("\n"):
stripped = line.strip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
match = REF_DEF_RE.match(line)
if match:
references[match.group(1).strip().lower()] = match.group(2)
return references
def _render(
markdown: str,
toc: list[dict],
used: set[str],
references: dict[str, str] | None = None,
) -> str:
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if references is None:
references = _collect_references(markdown)
out: list[str] = []
index = 0
total = len(lines)
while index < total:
line = lines[index]
stripped = line.strip()
if not stripped:
index += 1
continue
# 围栏代码块
fence = re.match(r"^(`{3,}|~{3,})\s*(\S*)\s*$", stripped)
if fence:
marker = fence.group(1)[0]
language = fence.group(2)
index += 1
code: list[str] = []
while index < total:
closing = lines[index].strip()
if closing.startswith(marker * 3) and set(closing) <= {marker, " "}:
index += 1
break
code.append(lines[index])
index += 1
class_attr = f' class="language-{html.escape(language)}"' if language else ""
body = html.escape("\n".join(code))
out.append(f"<pre class=\"code\"><code{class_attr}>{body}</code></pre>")
continue
# 缩进代码块
if line.startswith(" ") and line.strip():
code = []
while index < total and (
lines[index].startswith(" ") or not lines[index].strip()
):
if not lines[index].strip() and (
index + 1 >= total or not lines[index + 1].startswith(" ")
):
break
code.append(lines[index][4:])
index += 1
out.append(
'<pre class="code"><code>' + html.escape("\n".join(code)) + "</code></pre>"
)
continue
# 链接引用定义:单独成行,不渲染
if REF_DEF_RE.match(line):
index += 1
continue
# ATX 标题
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
level = len(heading.group(1))
text = heading.group(2).rstrip("#").strip()
anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used)
if level in (2, 3):
toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor})
out.append(
f'<h{level} id="{html.escape(anchor, quote=True)}">'
f"{render_inline(text, references)}</h{level}>"
)
index += 1
continue
# 分隔线
if HR_RE.match(line):
out.append("<hr>")
index += 1
continue
# 表格
if "|" in stripped and index + 1 < total and _is_table_separator(lines[index + 1]):
table_html, index = _render_table(lines, index, references)
out.append(table_html)
continue
# 引用块
if stripped.startswith(">"):
quoted = []
while index < total and lines[index].strip().startswith(">"):
quoted.append(lines[index].strip().lstrip(">").strip())
index += 1
out.append(
"<blockquote>"
+ _render("\n".join(quoted), toc, used, references)
+ "</blockquote>"
)
continue
# 列表
if LIST_ITEM_RE.match(stripped):
list_html, index = _render_list(lines, index, toc, used, references)
out.append(list_html)
continue
# 段落(若整段之后紧跟 === 或 ---,其实是 setext 标题)
parts: list[str] = []
while index < total and not _starts_block(lines, index):
parts.append(lines[index].strip())
index += 1
text = " ".join(parts)
underline = lines[index].strip() if index < total else ""
if parts and _is_setext_underline(underline):
level = 1 if set(underline) == {"="} else 2
anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used)
if level in (2, 3):
toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor})
out.append(
f'<h{level} id="{html.escape(anchor, quote=True)}">'
f"{render_inline(text, references)}</h{level}>"
)
index += 1
continue
out.append("<p>" + render_inline(text, references) + "</p>")
return "\n".join(out)

911
website/selftest.py Normal file
View File

@@ -0,0 +1,911 @@
"""网站构建脚本的自检:用人造包检验通用性。
人造包刻意与本仓库的 guan 包结构不同,覆盖各种「源码大改」后可能出现的形态:
包一(mypkg)
* 包名不同(mypkg)、目录布局不同(<root>/src/mypkg)
* 元数据写在 pyproject.toml 的 [project] 表里(不是 setup.cfg)
* 函数说明用 docstring,而不是定义上方的注释
* 带类型注解、async 函数、@deprecated 装饰器
* 有嵌套子包(mypkg.sub.extra)
* 有私有函数(下划线开头)与显式 __all__
* 有一个语法错误的文件(构建不能中断)
包二(edge)—— 各种边角情形
* 空文件、带 BOM 的文件、CRLF 换行的文件、GBK 编码的文件
* 文件名不是合法标识符(my-module.py)
* 函数内部再定义函数(不应出现在 API 顶层)
* 类里的 property 与 async 方法
* `from . import helper` 形式的相对导入
* README 里带本地图片、表格、任务列表
* README 与源码里出现 {{version}} 这类模板占位符(不能被替换掉)
包三(empty)—— 什么都没有
* 没有任何函数时,「API 参考」页应当自动消失
用法:
python website/selftest.py
"""
from __future__ import annotations
import json
import re
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import build_site # noqa: E402
import extract # noqa: E402
import i18n # noqa: E402
import md_render # noqa: E402
FAILURES: list[str] = []
def check(condition: bool, message: str) -> None:
if condition:
print(f" ok {message}")
else:
print(f" FAIL {message}")
FAILURES.append(message)
# --------------------------------------------------------------------------
# 包一:结构与人造包完全不同
# --------------------------------------------------------------------------
PYPROJECT = """\
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
[project]
name = "mypkg"
version = "9.9.9"
description = "A synthetic package for testing"
requires-python = ">=3.10"
authors = [{name = "Tester", email = "tester@example.com"}]
[project.urls]
Homepage = "https://example.com/mypkg"
Repository = "https://github.com/example/mypkg"
[tool.setuptools]
packages = ["mypkg", "mypkg.sub"]
"""
INIT = '''\
"""A synthetic package."""
from .core import *
from .sub import *
from .extra_only import special
__all__ = ["greet", "VERSION", "special"]
VERSION = "9.9.9"
'''
CORE = '''\
"""Core helpers of the synthetic package."""
def greet(name: str = "world") -> str:
"""Return a greeting for *name*.
This description comes from a docstring, not from a comment above.
"""
return f"hello {name}"
def _hidden_helper(x: int) -> int:
"""Not exported because of the leading underscore."""
return x
async def fetch(url: str) -> str:
"""An async function."""
return url
@deprecated
def old_api():
"""Deprecated on purpose."""
return 1
def table():
"""Return a literal table."""
return {"alpha": 1, "beta": 2}
def use_greet():
"""Call another function through the package namespace."""
import mypkg
return mypkg.greet("there")
'''
EXTRA_ONLY = '''\
def special():
"""Only exported through an explicit import."""
return 42
'''
SUB_INIT = '''\
"""A subpackage."""
from .extra import *
'''
SUB_EXTRA = '''\
def sub_function(x):
"""A function living in a nested subpackage."""
return x
SUB_CONSTANTS = {
"one": 1,
"two": 2,
}
'''
BROKEN = '''\
def broken(
'''
def make_package(root: Path) -> Path:
"""在 root 下造出人造包,返回包源码目录。"""
package = root / "src" / "mypkg"
(package / "sub").mkdir(parents=True)
(root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8")
(package / "__init__.py").write_text(INIT, encoding="utf-8")
(package / "core.py").write_text(CORE, encoding="utf-8")
(package / "extra_only.py").write_text(EXTRA_ONLY, encoding="utf-8")
(package / "broken.py").write_text(BROKEN, encoding="utf-8")
(package / "sub" / "__init__.py").write_text(SUB_INIT, encoding="utf-8")
(package / "sub" / "extra.py").write_text(SUB_EXTRA, encoding="utf-8")
(root / "README.md").write_text(
"# mypkg\n\nA synthetic package.\n\n+ one\n+ two\n", encoding="utf-8"
)
(root / "INSTALLATION.md").write_text(
"## Installation\n\n```bash\npip install --upgrade mypkg\n```\n",
encoding="utf-8",
)
return package
# --------------------------------------------------------------------------
# 包二:边角情形
# --------------------------------------------------------------------------
EDGE_PYPROJECT = """\
[project]
name = "edge"
version = "0.1"
description = "Edge cases"
"""
EDGE_INIT = '''\
"""Edge case package."""
import os
import json as js
from . import helper
from .plain import plain_function
'''
EDGE_PLAIN = '''\
"""Plain module."""
def plain_function():
"""A plain function."""
return 1
def outer():
"""A function with a nested function inside."""
def inner():
return 2
return inner()
def tricky():
"""模板占位符 {{version}} 不能被替换;<script>alert(1)</script> 必须被转义。"""
return 1
def plain():
"""Module-level function named plain."""
return 0
def use_widget():
"""Call a class of the same package, to exercise cross references."""
from .cls import Widget
return Widget()
class Container:
"""A class whose method has the same name as a module-level function."""
def plain(self):
"""Method named plain."""
return 0
'''
EDGE_CLS = '''\
"""A class with fancy members."""
class Widget:
"""A widget."""
kind = "widget"
@property
def size(self) -> int:
"""The size."""
return 1
async def refresh(self) -> None:
"""Refresh the widget."""
def plain(self):
"""A plain method."""
return 3
'''
def make_edge_package(root: Path) -> Path:
"""造出各种边角情形的包。"""
package = root / "src" / "edge"
package.mkdir(parents=True)
(root / "pyproject.toml").write_text(EDGE_PYPROJECT, encoding="utf-8")
(package / "__init__.py").write_text(EDGE_INIT, encoding="utf-8")
(package / "plain.py").write_text(EDGE_PLAIN, encoding="utf-8")
(package / "cls.py").write_text(EDGE_CLS, encoding="utf-8")
(package / "helper.py").write_text("", encoding="utf-8") # 空文件
# 带 BOM 的文件
(package / "bom.py").write_text(
'"""With BOM."""\n\n\ndef bom_func():\n """BOM function."""\n return 1\n',
encoding="utf-8-sig",
)
# CRLF 换行
(package / "crlf.py").write_bytes(
b'"""CRLF module."""\r\n\r\n\r\ndef crlf_func():\r\n """CRLF function."""\r\n return 1\r\n'
)
# GBK 编码
(package / "gbk.py").write_bytes(
'"""GBK 模块。"""\n\n\ndef gbk_func():\n """中文说明。"""\n return 1\n'.encode(
"gbk"
)
)
# 文件名不是合法标识符
(package / "my-module.py").write_text(
'"""Odd name."""\n\n\ndef odd_func():\n """Odd function."""\n return 1\n',
encoding="utf-8",
)
# README:本地图片 + 表格 + 任务列表 + 模板占位符
(root / "docs").mkdir()
(root / "docs" / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n")
(root / "README.md").write_text(
"# Edge\n\n"
"![logo](docs/logo.png)\n\n"
"版本号是 {{version}},不要被替换掉。\n\n"
"| 列 | 值 |\n|---|---|\n| a | 1 |\n| b |\n\n"
"- [ ] 待办\n- [x] 完成\n",
encoding="utf-8",
)
return package
# --------------------------------------------------------------------------
# 包四:元数据的各种退化情形
# --------------------------------------------------------------------------
META_INIT = '''\
"""Meta package."""
__version__ = "7.7.7"
def only():
"""Only one function."""
return 1
'''
def make_meta_package(root: Path) -> Path:
"""没有可读元数据时,应当退回到目录名与 __init__.py 里的 __version__。"""
package = root / "src" / "meta"
package.mkdir(parents=True)
(package / "__init__.py").write_text(META_INIT, encoding="utf-8")
# 故意写坏的 pyproject.toml,不应让构建崩溃
(root / "pyproject.toml").write_text(
"[project\nname = broken\n", encoding="utf-8"
)
return package
# --------------------------------------------------------------------------
# 包五:setup.cfg 里用 attr: 写版本号
# --------------------------------------------------------------------------
ATTR_INIT = '''\
"""Attr package."""
__version__ = "5.6.7"
def only():
"""Only one function."""
return 1
'''
def make_attr_package(root: Path) -> Path:
package = root / "src" / "attrpkg"
package.mkdir(parents=True)
(package / "__init__.py").write_text(ATTR_INIT, encoding="utf-8")
(root / "setup.cfg").write_text(
"[metadata]\n"
"name = attrpkg\n"
"version = attr: attrpkg.__version__\n"
"description = Version read from the package itself\n",
encoding="utf-8",
)
(root / "README.md").write_text("# attrpkg\n", encoding="utf-8")
return package
# --------------------------------------------------------------------------
# 包六:只有类,没有模块级函数
# --------------------------------------------------------------------------
CLASS_ONLY_INIT = '''\
"""Only classes."""
from .thing import Thing
'''
def make_class_only_package(root: Path) -> Path:
package = root / "src" / "classonly"
package.mkdir(parents=True)
(package / "__init__.py").write_text(CLASS_ONLY_INIT, encoding="utf-8")
(package / "thing.py").write_text(
'"""A thing."""\n'
"\n"
"\n"
"class Thing:\n"
' """A thing."""\n'
"\n"
" def go(self):\n"
' """Go."""\n'
" return 1\n",
encoding="utf-8",
)
(root / "README.md").write_text("# classonly\n", encoding="utf-8")
return package
# --------------------------------------------------------------------------
# 包三:空包
# --------------------------------------------------------------------------
def make_empty_package(root: Path) -> Path:
package = root / "src" / "empty"
package.mkdir(parents=True)
(root / "pyproject.toml").write_text(
'[project]\nname = "empty"\nversion = "0"\ndescription = "Nothing here"\n',
encoding="utf-8",
)
(package / "__init__.py").write_text('"""Nothing here."""\n', encoding="utf-8")
(root / "README.md").write_text("# Empty\n", encoding="utf-8")
return package
def check_meta(data: dict, output: Path) -> None:
api = json.loads((output / "api.json").read_text(encoding="utf-8"))
print("\n元数据退化")
check(api["package"]["name"] == "meta", "元数据坏掉时退回目录名")
check(api["package"]["version"] == "7.7.7", "版本退回 __init__.py 里的 __version__")
check((output / "index.html").exists(), "元数据坏掉时网站照常生成")
check(not build_site.verify_output(output), "生成的页面没有断链")
# --------------------------------------------------------------------------
# 检查项
# --------------------------------------------------------------------------
def build(package: Path, tmp: Path, name: str) -> tuple[dict, Path]:
output = tmp / name
data = build_site.generate(
source_dir=package,
output=output,
readme_path=package.parent.parent / "README.md",
installation_path=package.parent.parent / "INSTALLATION.md",
# 人造包不带上仓库里真实的 tutorials/,免得混入无关内容
tutorials_dir=package.parent.parent / "没有这个目录",
quiet=True,
)
return data, output
def check_basic(data: dict, output: Path) -> None:
api = json.loads((output / "api.json").read_text(encoding="utf-8"))
print("\n包信息")
check(api["package"]["name"] == "mypkg", "包名来自元数据")
check(api["package"]["version"] == "9.9.9", "版本来自元数据")
check(api["package"]["import_name"] == "mypkg", "导入名来自包目录")
check(api["package"]["requires_python"] == ">=3.10", "requires-python 正确解析")
check(
api["package"]["urls"].get("Homepage") == "https://example.com/mypkg",
"[project.urls] 表正确解析",
)
check(api["package"]["author"] == "Tester", "PEP 621 的 authors 正确解析")
check(api["package"]["author_email"] == "tester@example.com", "作者邮箱正确解析")
print("\n模块发现")
names = [m["name"] for m in api["modules"]]
check("core" in names, "识别到 core 模块")
check("sub.extra" in names, "识别到嵌套子包模块 sub.extra")
check("mypkg" in names, "包入口被识别为模块")
check("broken" in names, "语法错误的文件仍被列出")
print("\n函数提取")
functions = {
f["name"]: f
for module in api["modules"]
for f in module["functions"]
}
check("greet" in functions, "识别到函数 greet")
check(
"Return a greeting" in functions["greet"]["description"],
"docstring 被用作函数说明(源码没有注释时)",
)
check(
"This description comes from a docstring" in functions["greet"]["docstring"],
"完整 docstring 保留在数据里",
)
check(functions["greet"]["params"][0]["annotation"] == "str", "参数类型注解解析")
check(
functions["greet"]["params"][0]["default"] in ('"world"', "'world'"),
"默认值解析",
)
check(functions["greet"]["returns"] == "str", "返回类型解析")
check(functions["fetch"]["is_async"], "async 函数被标记")
check(functions["old_api"]["deprecated"], "@deprecated 装饰器被识别")
check("_hidden_helper" in functions, "私有函数仍被提取(只是不导出)")
check(
functions["_hidden_helper"]["exported"] is False,
"下划线开头的函数不被标记为导出",
)
check(functions["greet"]["exported"] is True, "导出的函数被标记")
print("\n导出关系")
exports = api["exports"]
check("greet" in exports, "from .core import * 的名字被导出")
check("sub_function" in exports, "子包的名字被导出")
check("special" in exports, "显式 from .extra_only import special 被导出")
check("_hidden_helper" not in exports, "私有名字不在导出列表里")
print("\n数据表")
tables = [table for module in api["modules"] for table in module["data"]]
table_names = {t["name"] for t in tables}
check("table" in table_names, "函数返回的字面量表被识别")
check("SUB_CONSTANTS" in table_names, "模块级字面量表被识别")
check(any(t["origin"] == "module" for t in tables), "模块级表被标注来源")
print("\n容错")
broken = next(m for m in api["modules"] if m["name"] == "broken")
check(bool(broken["error"]), "语法错误被记录而不是抛异常")
print("\n页面生成")
for filename in ("index.html", "api.html", "about.html", "installation.html"):
check((output / filename).exists(), f"生成了 {filename}")
check(not (output / "modules.html").exists(), "不再生成模块页")
check(not (output / "data.html").exists(), "不再生成数据页")
installation = (output / "installation.html").read_text(encoding="utf-8")
check(
"pip install --upgrade mypkg" in installation,
"安装页用真实的包名渲染 INSTALLATION.md",
)
index = (output / "index.html").read_text(encoding="utf-8")
check("mypkg" in index, "首页出现包名")
check("guan." not in index, "首页没有残留旧包名")
check("import guan" not in index, "首页没有残留旧包的导入写法")
check("pip install --upgrade mypkg" in index, "安装命令用真实的包名")
check('href="installation.html"' in index, "首页有指向安装页的链接")
api_html = (output / "api.html").read_text(encoding="utf-8")
check("mypkg.greet" in api_html, "交叉引用使用真实的导入名")
check("guan." not in api_html, "API 页没有残留旧包名")
check(not build_site.verify_output(output), "生成的页面没有断链")
def check_edge(data: dict, output: Path) -> None:
api = json.loads((output / "api.json").read_text(encoding="utf-8"))
names = [m["name"] for m in api["modules"]]
print("\n各种编码与文件形态")
check("helper" in names, "空文件也被识别为模块")
check("bom" in names, "带 BOM 的文件被识别")
check("crlf" in names, "CRLF 换行的文件被识别")
check("gbk" in names, "GBK 编码的文件被识别")
check("my-module" in names, "文件名不是标识符时也能识别")
functions = {
f["name"]: f
for module in api["modules"]
for f in module["functions"]
}
check("bom_func" in functions, "BOM 文件里的函数被解析")
check("crlf_func" in functions, "CRLF 文件里的函数被解析")
check("gbk_func" in functions, "GBK 文件里的函数被解析")
check(
functions.get("gbk_func", {}).get("description") == "中文说明。",
"GBK 文件里的中文说明没有乱码",
)
check("odd_func" in functions, "特殊文件名的模块里的函数被解析")
check("inner" not in functions, "函数内部的嵌套函数不出现在 API 顶层")
print("\n类")
classes = {
c["name"]: c
for module in api["modules"]
for c in module["classes"]
}
check("Widget" in classes, "识别到类")
widget = classes.get("Widget", {})
method_names = {m["name"] for m in widget.get("methods", [])}
check("size" in method_names, "property 被当作方法记录")
check("plain" in method_names, "普通方法被记录")
check(
any(m["name"] == "refresh" and m["is_async"] for m in widget.get("methods", [])),
"类里的 async 方法被标记",
)
check("kind" in widget.get("attributes", []), "类属性被记录")
print("\n相对导入")
check("helper" in api["exports"], "from . import helper 被识别为导出")
check("os" not in api["exports"], "第三方库(import os)不会被当成导出")
check("js" not in api["exports"], "第三方库的别名(import json as js)不会被当成导出")
print("\nREADME 与资源")
about = (output / "about.html").read_text(encoding="utf-8")
check("{{version}}" in about, "README 里的 {{version}} 没有被模板替换掉")
check((output / "docs" / "logo.png").exists(), "README 引用的本地图片被复制过来")
check("docs/logo.png" in about, "图片的相对路径被保留")
check("<td></td>" in about, "表格缺失的单元格被补齐")
check("checkbox" in about, "任务列表被正确渲染")
check(not build_site.verify_output(output), "生成的页面没有断链")
print("\n源码里的模板占位符")
api_html = (output / "api.html").read_text(encoding="utf-8")
check("{{version}}" in api_html, "源码里的 {{version}} 没有被替换掉")
check(
"<script>alert(1)</script>" not in api_html,
"docstring 里的 HTML 被转义,不会被当成标签执行",
)
check("&lt;script&gt;" in api_html, "转义后的 HTML 实体出现在页面上")
print("\n同名函数")
duplicated = [
module
for module in api["modules"]
if any(f["name"] == "plain_function" for f in module["functions"])
]
check(len(duplicated) >= 1, "同名函数被分别记录")
ids = re.findall(r'id="([^"]+)"', api_html)
check(len(ids) == len(set(ids)), "页面里的 id 没有重复")
check("plain.Container.plain" in ids, "同名方法与函数各有各的锚点")
check("plain.plain" in ids, "模块级函数的锚点没有被方法覆盖")
check("cls.Widget" in ids, "类本身也有锚点")
print("\n交叉参考")
use_widget = next(
f
for module in api["modules"]
for f in module["functions"]
if f["name"] == "use_widget"
)
check("Widget" in use_widget["calls"], "直接 import 进来的包内函数也被认出来")
check('<a href="#cls.Widget">' in api_html, "交叉参考指向类自己的锚点")
check(not build_site.verify_output(output), "生成的页面没有断链")
def check_i18n(data: dict, output: Path) -> None:
print("\n中英文界面")
check(not i18n.missing_keys(), "两种语言的文案都写全了")
for filename in ("index.html", "api.html", "about.html", "installation.html"):
if not (output / filename).exists():
continue # 中文页面里就没有的(比如没有 INSTALLATION.md),英文也不该有
check(
(output / "en" / filename).exists(),
f"生成了 en/{filename}",
)
zh_index = (output / "index.html").read_text(encoding="utf-8")
en_index = (output / "en" / "index.html").read_text(encoding="utf-8")
check('<html lang="zh-CN">' in zh_index, "中文页面声明了语言")
check('<html lang="en">' in en_index, "英文页面声明了语言")
check(">首页<" in zh_index, "中文页面用中文导航")
check(">Home<" in en_index, "英文页面用英文导航")
check("模块一览" in zh_index, "中文页面的界面文字是中文")
check("模块一览" not in en_index, "英文页面的界面文字是英文")
check(">Modules<" in en_index, "英文页面有对应的英文标题")
print("\n语言切换")
check('href="en/index.html"' in zh_index, "中文页面链到英文页面")
check('href="../index.html"' in en_index, "英文页面链回中文页面")
zh_api = (output / "api.html").read_text(encoding="utf-8")
en_api = (output / "en" / "api.html").read_text(encoding="utf-8")
check('href="en/api.html"' in zh_api, "API 页也能切到另一种语言")
check('href="../api.html"' in en_api, "英文 API 页能切回中文")
print("\n文件内容保持原样")
# README、源码里的说明不翻译:两种语言的页面里都应当原样出现
zh_about = (output / "about.html").read_text(encoding="utf-8")
en_about = (output / "en" / "about.html").read_text(encoding="utf-8")
check("版本号是 {{version}}" in zh_about, "中文页面里是 README 原文")
check("版本号是 {{version}}" in en_about, "英文页面里也是 README 原文")
check(
"A plain function." in en_api,
"英文页面里保留源码里的英文说明",
)
check("The size." in en_api, "英文页面里保留 docstring 原文")
print("\n两种语言的链接都是通的")
check(not build_site.verify_output(output), "生成的页面没有断链")
check((output / "en" / "static" / "style.css").exists(), "英文目录里有自己的样式")
check((output / "en" / "docs" / "logo.png").exists(), "英文目录里也有 README 的图片")
def check_cli(tmp: Path) -> None:
print("\n命令行与异常处理")
try:
build_site.generate(source_dir=tmp / "does-not-exist", output=tmp / "nope")
check(False, "不存在的源码目录应当报错")
except SystemExit:
check(True, "不存在的源码目录会给出明确报错而不是崩溃")
except Exception as error: # noqa: BLE001
check(False, f"不存在的源码目录应当报 SystemExit,实际是 {error!r}")
try:
build_site.generate(source_dir=tmp / "not-a-dir.txt", output=tmp / "nope2")
check(False, "指向文件的源码路径应当报错")
except SystemExit:
check(True, "源码路径不是目录时会给出明确报错")
except Exception as error: # noqa: BLE001
check(False, f"源码路径不是目录时应当报 SystemExit,实际是 {error!r}")
print("\n断链检查本身有效")
broken = tmp / "broken-site"
broken.mkdir(parents=True, exist_ok=True)
(broken / "index.html").write_text(
'<a href="missing.html">x</a><a href="#nope">y</a>', encoding="utf-8"
)
problems = build_site.verify_output(broken)
check(len(problems) == 2, "断链与失效锚点都能被查出来")
def check_empty(data: dict, output: Path) -> None:
print("\n没有内容时的页面取舍")
check(not (output / "api.html").exists(), "没有函数时不生成 API 参考页")
check(not (output / "modules.html").exists(), "不再生成模块页")
check(not (output / "data.html").exists(), "不再生成数据页")
check((output / "index.html").exists(), "首页仍然生成")
check(
not (output / "installation.html").exists(),
"没有 INSTALLATION.md 时不生成安装页",
)
index = (output / "index.html").read_text(encoding="utf-8")
check("api.html" not in index, "首页没有链到不存在的 API 页")
check(
"installation.html" not in index,
"首页没有链到不存在的安装页",
)
check("modules.html" not in index, "首页没有链到已经不存在的模块页")
check("data.html" not in index, "首页没有链到已经不存在的数据页")
check(not build_site.verify_output(output), "生成的页面没有断链")
def check_attr(data: dict, output: Path) -> None:
api = json.loads((output / "api.json").read_text(encoding="utf-8"))
print("\n版本号的 attr: 写法")
check(api["package"]["version"] == "5.6.7", "attr: 形式的版本号被解析出来")
check(
api["package"]["description"] == "Version read from the package itself",
"setup.cfg 里的描述被正确读取",
)
check(not build_site.verify_output(output), "生成的页面没有断链")
def check_class_only(data: dict, output: Path) -> None:
print("\n只有类的时候")
check((output / "api.html").exists(), "只有类时也会生成 API 参考页")
api_html = (output / "api.html").read_text(encoding="utf-8")
check("thing.Thing.go" in api_html, "类的方法有正确的锚点")
check(not build_site.verify_output(output), "生成的页面没有断链")
TUTORIALS_README = """\
# Tutorials
Some examples.
## 一、日常场景 / Daily scenarios
| File | 说明 | Requirements |
| --- | --- | --- |
| `01_hello.py` | 打个招呼 | numpy |
"""
TUTORIALS_SCRIPT = """\
# 1. 打个招呼
#
# 来源 / Source: https://example.com/post
#
import numpy as np
print(np.zeros(3))
"""
def make_tutorials(root: Path) -> Path:
"""造一个 tutorials/ 目录。"""
directory = root / "tutorials"
(directory / "daily_scenarios").mkdir(parents=True, exist_ok=True)
(directory / "README.md").write_text(TUTORIALS_README, encoding="utf-8")
(directory / "daily_scenarios" / "01_hello.py").write_text(
TUTORIALS_SCRIPT, encoding="utf-8"
)
return directory
def check_tutorials(tmp: Path) -> None:
print("\n教程页")
tutorials_dir = make_tutorials(tmp / "t")
package = make_package(tmp / "t")
output = tmp / "t" / "site-t"
data = build_site.generate(
source_dir=package,
output=output,
readme_path=tmp / "t" / "README.md",
installation_path=tmp / "t" / "INSTALLATION.md",
tutorials_dir=tutorials_dir,
quiet=True,
)
check((output / "tutorials.html").exists(), "有 tutorials/ 时生成教程页")
html = (output / "tutorials.html").read_text(encoding="utf-8")
check("一、日常场景" in html, "分组标题来自 README 的章节")
check("打个招呼" in html, "脚本标题与说明被收进来")
check("np.zeros(3)" in html, "脚本源码被收进来")
check(
"href=\"tutorials/daily_scenarios/01_hello.py\"" in html,
"页面给出脚本的下载地址",
)
check(
(output / "tutorials" / "daily_scenarios" / "01_hello.py").exists(),
"脚本被复制到站点里",
)
api = json.loads((output / "api.json").read_text(encoding="utf-8"))
check(bool(api.get("tutorials", {}).get("files")), "api.json 里带上教程索引")
check(
"code" not in api["tutorials"]["groups"][0]["files"][0],
"api.json 里不重复存整段源码",
)
check(data["tutorials"]["count"] == 1, "教程数量统计正确")
check(not build_site.verify_output(output), "生成的页面没有断链")
def check_markdown() -> None:
print("\nMarkdown 渲染")
html = md_render.render("- a\n1. b\n")
check("<ul>" in html and "<ol>" in html, "不同类型的列表分开渲染")
html = md_render.render("- a\n - b\n - c\n")
check(html.count("<ul>") == 3, "多层嵌套列表不会被当成代码块")
html = md_render.render("[x](https://example.com/a_(b))")
check('href="https://example.com/a_(b)"' in html, "链接地址里的括号不会被截断")
html = md_render.render("see https://example.com/a_(b).")
check('href="https://example.com/a_(b)"' in html, "裸露网址里的括号与句号处理正确")
html = md_render.render("| a | b |\n|---|---|\n| 1 |\n")
check("<td></td>" in html, "表格缺失的单元格被补齐")
check("<blockquote>" in md_render.render("> 引用\n"), "引用块被渲染")
check(
"<em>x</em>" in md_render.render("*x*\n"),
"斜体被渲染",
)
# --------------------------------------------------------------------------
def run() -> int:
tmp = Path(tempfile.mkdtemp(prefix="site-selftest-"))
try:
print("== 人造包一:结构完全不同 ==")
data, output = build(make_package(tmp / "a"), tmp / "a", "site-a")
check_basic(data, output)
print("\n== 人造包二:边角情形 ==")
data, output = build(make_edge_package(tmp / "b"), tmp / "b", "site-b")
check_edge(data, output)
print("\n== 人造包三:什么都没有 ==")
data, output = build(make_empty_package(tmp / "c"), tmp / "c", "site-c")
check_empty(data, output)
print("\n== 人造包四:元数据退化 ==")
data, output = build(make_meta_package(tmp / "d"), tmp / "d", "site-d")
check_meta(data, output)
print("\n== 人造包五:attr: 形式的版本号 ==")
data, output = build(make_attr_package(tmp / "e"), tmp / "e", "site-e")
check_attr(data, output)
print("\n== 人造包六:只有类 ==")
data, output = build(make_class_only_package(tmp / "f"), tmp / "f", "site-f")
check_class_only(data, output)
print("\n== 自动发现 ==")
discovered = extract.discover_package_dir([tmp / "a" / "src", tmp / "a"])
check(
discovered is not None and discovered.name == "mypkg",
"自动发现包目录",
)
check(
extract.discover_package_dir([Path(__file__).resolve().parent]) is None,
"网站脚本目录不会被误认成包",
)
print("\n== 教程页 ==")
check_tutorials(tmp)
print("\n== 中英文界面 ==")
data, output = build(make_edge_package(tmp / "g"), tmp / "g", "site-g")
check_i18n(data, output)
print("\n== Markdown ==")
check_markdown()
print("\n== 命令行与异常处理 ==")
check_cli(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
print()
if FAILURES:
print(f"{len(FAILURES)} 项检查未通过:")
for item in FAILURES:
print(f" - {item}")
return 1
print("全部检查通过。")
return 0
if __name__ == "__main__":
raise SystemExit(run())

BIN
website/static/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

485
website/static/site.js Normal file
View File

@@ -0,0 +1,485 @@
/* ==========================================================================
GUAN 官网 —— 交互脚本
站点是纯静态的,所以所有交互都放在这里,按功能分成若干独立的小模块。
每个模块都先判断页面上有没有对应的元素,没有就直接退出,
这样同一份脚本可以用在所有页面上。
========================================================================== */
(function () {
"use strict";
var root = document.documentElement;
/* 页面把自己的语言文案传进来(见 i18n.py),
这里做一次兜底,脚本单独拿出来调试时也不会报错。 */
var I18N = window.SITE_I18N || {};
function text(key, fallback) {
return I18N[key] || fallback || key;
}
/* ---------- 通用小工具 ---------- */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
try {
document.execCommand("copy");
resolve();
} catch (err) {
reject(err);
}
document.body.removeChild(area);
});
}
/* 给按钮加一个「已复制 → 恢复」的反馈 */
function flash(button, text, done) {
var original = button.getAttribute("data-label") || button.textContent;
button.setAttribute("data-label", original);
button.textContent = text;
clearTimeout(button._flashTimer);
button._flashTimer = setTimeout(function () {
button.textContent = original;
if (done) done();
}, 1500);
}
/* ==========================================================================
主题切换:默认跟随系统,用户点一次就固定下来
========================================================================== */
(function theme() {
var button = document.getElementById("theme-toggle");
if (!button) return;
function current() {
var explicit = root.getAttribute("data-theme");
if (explicit === "light" || explicit === "dark") return explicit;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function apply(mode) {
if (mode === "light" || mode === "dark") {
root.setAttribute("data-theme", mode);
} else {
root.removeAttribute("data-theme");
}
}
button.addEventListener("click", function () {
var next = current() === "dark" ? "light" : "dark";
apply(next);
try {
localStorage.setItem("guan-theme", next);
} catch (e) {}
button.setAttribute(
"title",
next === "dark"
? text("theme_to_light", "切换到浅色主题")
: text("theme_to_dark", "切换到深色主题")
);
button.setAttribute("aria-label", button.getAttribute("title"));
});
})();
/* ==========================================================================
窄屏菜单:展开 / 收起导航
========================================================================== */
(function navToggle() {
var toggle = document.getElementById("nav-toggle");
var nav = document.getElementById("site-nav");
if (!toggle || !nav) return;
function close() {
nav.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", text("menu_open", "打开菜单"));
}
toggle.addEventListener("click", function () {
var open = nav.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute(
"aria-label",
open ? text("menu_close", "关闭菜单") : text("menu_open", "打开菜单")
);
});
nav.addEventListener("click", function (e) {
if (e.target.tagName === "A") close();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") close();
});
window.addEventListener("resize", function () {
if (window.innerWidth > 900) close();
});
})();
/* ==========================================================================
复制:安装命令、代码块、函数链接
========================================================================== */
(function copyButtons() {
document.querySelectorAll(".copy[data-copy]").forEach(function (button) {
button.addEventListener("click", function () {
var value = button.getAttribute("data-copy");
copyText(value).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
});
// 给每个代码块加一个右上角的复制按钮
document.querySelectorAll("pre.code").forEach(function (pre) {
var code = pre.querySelector("code");
if (!code) return;
var button = document.createElement("button");
button.type = "button";
button.className = "code-copy";
button.textContent = text("copy", "复制");
button.title = text("copy_code", "复制代码");
button.setAttribute("aria-label", text("copy_code", "复制代码"));
button.addEventListener("click", function () {
copyText(code.innerText).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
pre.appendChild(button);
});
})();
/* ==========================================================================
下载:教程页的「下载脚本」
直接用 <a download> 的话,本地以 file:// 方式预览时浏览器会忽略
download 属性、把源码显示出来。这里改成拿页面里的源码生成文件再下载,
这样无论站点怎么打开,点击都会启动下载程序。
========================================================================== */
(function downloadLinks() {
document.querySelectorAll("a[data-download]").forEach(function (link) {
link.addEventListener("click", function (event) {
var card = link.closest(".tut-card");
var code = card && card.querySelector("pre.code code");
if (!code) return; // 页面里没有源码,就用浏览器自己的下载行为
event.preventDefault();
var blob = new Blob([code.textContent], {
type: "text/x-python;charset=utf-8"
});
var url = URL.createObjectURL(blob);
var anchor = document.createElement("a");
anchor.href = url;
anchor.download = link.getAttribute("data-download");
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
});
})();
/* ==========================================================================
回到顶部
========================================================================== */
(function toTop() {
var button = document.getElementById("to-top");
if (!button) return;
var pending = false;
function update() {
pending = false;
button.classList.toggle("is-visible", window.scrollY > 420);
}
function onScroll() {
if (pending) return;
pending = true;
window.requestAnimationFrame(update);
}
button.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
});
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update();
})();
/* ==========================================================================
滚动跟随高亮:API 页的模块列表、关于页 / 教程页的目录
--------------------------------------------------------------------------
侧栏里最后一个条目往往太短,页面滚不到它的标题上方那条基线,
只按滚动位置判断的话,高亮会留在上一项上,点它也没反应。
所以点一下的时候先把高亮锁在被点的条目上,等用户自己滚动时
再交还给滚动位置决定;页面滚到底部时,最后一个条目也会被点亮。
========================================================================== */
function setupScrollSpy(options) {
var links = options.links;
var targets = options.targets;
var offset = options.offset || 120;
var container = options.container || null;
if (!links.length || !targets.length) return;
var locked = null; // 点击后锁定的目标
var settledY = null; // 跳转停下来时的滚动位置
var settleTimer = null;
var pending = false;
function hrefId(link) {
var href = link.getAttribute("href") || "";
if (href.charAt(0) !== "#") return "";
return decodeURIComponent(href.slice(1));
}
function isVisible(el) {
return el.getClientRects().length > 0;
}
/* 当前所在的目标:最后一个越过基线的;页面到底了就是最后一个 */
function pick() {
var doc = document.documentElement;
var shown = targets.filter(isVisible);
if (!shown.length) return null;
if (window.innerHeight + window.pageYOffset >= doc.scrollHeight - 4) {
return shown[shown.length - 1];
}
var current = null;
shown.forEach(function (target) {
if (target.getBoundingClientRect().top <= offset) current = target;
});
return current || shown[0];
}
/* 让被点亮的条目留在侧栏自身的可视范围内 */
function keepVisible(link) {
if (!container) return;
var box = container.getBoundingClientRect();
var item = link.getBoundingClientRect();
if (item.top < box.top) container.scrollTop -= box.top - item.top + 6;
else if (item.bottom > box.bottom) container.scrollTop += item.bottom - box.bottom + 6;
}
function apply(target) {
links.forEach(function (link) {
var active = !!target && hrefId(link) === target.id;
link.classList.toggle("is-active", active);
if (active) keepVisible(link);
});
}
/* 滚动停下来之后记下位置,用来分辨「程序滚动」和「用户滚动」 */
function markSettled() {
clearTimeout(settleTimer);
settleTimer = setTimeout(function () {
settledY = window.scrollY;
apply(locked || pick());
}, 200);
}
function update() {
pending = false;
// 用户自己滚动了 → 解除点击时的锁定
if (locked && settledY !== null && Math.abs(window.scrollY - settledY) > 4) {
locked = null;
settledY = null;
}
apply(locked || pick());
markSettled();
}
function onScroll() {
if (pending) return;
pending = true;
window.requestAnimationFrame(update);
}
links.forEach(function (link) {
link.addEventListener("click", function () {
var id = hrefId(link);
if (!id) return;
var target = document.getElementById(id);
if (!target) return;
locked = target;
settledY = null;
apply(target);
markSettled();
});
});
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
apply(pick());
markSettled();
}
/* ==========================================================================
API 页:模块速查、模块高亮、函数筛选
========================================================================== */
(function apiPage() {
var layout = document.querySelector(".api-layout");
if (!layout) return;
var modules = Array.prototype.slice.call(document.querySelectorAll(".module"));
/* 每个模块下方加一排函数快捷入口 */
modules.forEach(function (module) {
var funcs = Array.prototype.slice.call(module.querySelectorAll(".func"));
if (funcs.length < 2) return;
var list = document.createElement("ul");
list.className = "module-index";
funcs.forEach(function (func) {
var id = func.id;
if (!id) return;
var name = id.split(".").pop();
var li = document.createElement("li");
var link = document.createElement("a");
link.href = "#" + id;
link.textContent = name;
link.setAttribute("data-target", id);
li.appendChild(link);
list.appendChild(li);
});
var meta = module.querySelector(".module-meta");
if (meta) meta.insertAdjacentElement("afterend", list);
else module.insertBefore(list, module.firstChild);
});
/* 函数卡片右上角的「复制链接」按钮 */
document.querySelectorAll(".func").forEach(function (func) {
if (!func.id) return;
var button = document.createElement("button");
button.type = "button";
button.className = "anchor-btn";
button.textContent = "#";
button.title = text("copy_anchor", "复制本函数的链接");
button.setAttribute("aria-label", text("copy_anchor", "复制本函数的链接"));
button.addEventListener("click", function () {
var url = location.href.split("#")[0] + "#" + func.id;
copyText(url).then(function () {
flash(button, text("copied", "已复制"));
}, function () {});
});
var heading = func.querySelector("h3");
if (heading) heading.appendChild(button);
});
/* 顶部筛选框:按函数名 / 说明过滤 */
var total = document.querySelectorAll(".func").length;
var filter = document.createElement("div");
filter.className = "api-filter";
filter.innerHTML =
'<input type="search" placeholder="' +
text("filter_placeholder", "在本页筛选函数…") +
'" aria-label="' + text("filter_label", "筛选函数") +
'" autocomplete="off">' +
'<span class="filter-count"></span>' +
'<button class="filter-clear" type="button" hidden>' +
text("filter_clear", "清除") + "</button>";
// 放在正文列的上方,跟左侧模块列表对齐
var body = document.querySelector(".api-body");
if (body) body.insertBefore(filter, body.firstChild);
else layout.parentNode.insertBefore(filter, layout);
var field = filter.querySelector("input");
var count = filter.querySelector(".filter-count");
var clear = filter.querySelector(".filter-clear");
function applyFilter() {
var q = field.value.trim().toLowerCase();
clear.hidden = !q;
var shown = 0;
document.querySelectorAll(".func").forEach(function (func) {
var content = (func.textContent || "").toLowerCase();
var hit = !q || content.indexOf(q) !== -1;
func.classList.toggle("is-hidden", !hit);
if (hit) shown += 1;
});
// 一个函数都不剩的模块整块隐藏
modules.forEach(function (module) {
var visible = module.querySelectorAll(".func:not(.is-hidden)").length;
module.style.display = visible ? "" : "none";
var index = module.querySelector(".module-index");
if (index) {
index.querySelectorAll("a").forEach(function (link) {
var target = document.getElementById(link.getAttribute("data-target"));
link.style.display = target && !target.classList.contains("is-hidden") ? "" : "none";
});
}
});
count.textContent = q
? text("filter_count", "显示 {shown} / {total} 个函数")
.replace("{shown}", shown)
.replace("{total}", total)
: "";
}
field.addEventListener("input", applyFilter);
clear.addEventListener("click", function () {
field.value = "";
applyFilter();
field.focus();
});
/* 左侧模块列表:滚动时高亮当前所在的模块 */
var nav = document.querySelector(".api-nav");
var navLinks = Array.prototype.slice.call(
document.querySelectorAll(".api-nav li a")
);
if (!navLinks.length) return;
setupScrollSpy({
links: navLinks,
targets: modules,
offset: 120,
container: nav,
});
})();
/* ==========================================================================
关于页 / 教程页的目录:跟随滚动高亮当前章节
========================================================================== */
(function tocScrollSpy() {
var toc = document.querySelector(".toc");
if (!toc) return;
var links = Array.prototype.slice.call(toc.querySelectorAll("a[href^='#']"));
if (!links.length) return;
var targets = links
.map(function (link) {
return document.getElementById(decodeURIComponent(link.getAttribute("href").slice(1)));
})
.filter(Boolean);
if (!targets.length) return;
setupScrollSpy({
links: links,
targets: targets,
offset: 140,
container: toc,
});
})();
})();

891
website/static/style.css Normal file
View File

@@ -0,0 +1,891 @@
/* ==========================================================================
GUAN 软件包官网 —— 样式表
设计要点:
* 浅色 / 深色两套配色,跟随系统,也可手动切换(html[data-theme])
* 版心宽屏铺满,两侧留白随视口伸缩
* 所有可交互元素都有明显的悬停与键盘焦点态
========================================================================== */
:root {
color-scheme: light;
--bg: #f5f7f8;
--bg-grad: radial-gradient(1200px 600px at 15% -10%, #eaf2ee 0%, transparent 60%),
radial-gradient(900px 500px at 95% 0%, #eef1f7 0%, transparent 55%);
--panel: #ffffff;
--panel-2: #fafbfc;
--border: #e3e7ea;
--border-strong: #cfd6db;
--text: #16191d;
--text-soft: #3c434b;
--muted: #69727c;
--accent: #2f6f4f;
--on-accent: #ffffff;
--accent-hover: #265c41;
--accent-soft: #e7f1eb;
--accent-line: #bcd8c9;
--warn: #9a5518;
--warn-soft: #fdf0e2;
--code-bg: #f1f4f6;
--code-head: #e9edf0;
/* ---- 字号阶梯:全站统一,字号只从这里取值 ---- */
--fs-2xs: .75rem; /* 12 徽标 / 键盘提示 */
--fs-xs: .8125rem; /* 13 小标签 / 次要说明 */
--fs-sm: .875rem; /* 14 辅助说明 / 代码 */
--fs-md: .9375rem; /* 15 控件 / 次级正文 */
--fs-body: 1rem; /* 16 正文 */
--fs-lg: 1.125rem; /* 18 小标题 */
--fs-xl: 1.5rem; /* 24 区块标题 */
--fs-display: clamp(2.25rem, 4.8vw, 3.5rem); /* 主视觉大标题 */
--fs-title: clamp(1.75rem, 3vw, 2.25rem); /* 页面标题 */
--fs-lead: clamp(1.0625rem, 1.2vw, 1.1875rem); /* 导语 */
--fs-stat: clamp(1.375rem, 2.2vw, 1.75rem); /* 统计数字 */
--fs-module: clamp(1.25rem, 2.2vw, 1.5625rem); /* 模块标题 */
--radius: 10px;
--radius-lg: 16px;
/* ---- 字体栈 ----
只用系统自带字体:不依赖网络字体,国内访问更快、首屏也不闪。
拉丁字与中文字分段挑选:拉丁优先用 Segoe UI / PingFang 这类屏显细腻的,
中文再依次回落到思源 / 雅黑,保证任何系统都不缺字。 */
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system,
BlinkMacSystemFont, Roboto, "Helvetica Neue", Arial, "PingFang SC",
"HarmonyOS Sans SC", "Noto Sans SC", "Noto Sans CJK SC",
"Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
/* 标题专用:Win11 自带的 Segoe UI Variable Display 是大字号光学尺寸,
笔画比正文字体更实、更适合主视觉;其他系统回落到常规黑体 */
--display: "Segoe UI Variable Display", "Segoe UI Variable Text",
"Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, Roboto,
"Helvetica Neue", Arial, "PingFang SC", "HarmonyOS Sans SC",
"Noto Sans SC", "Noto Sans CJK SC", "Source Han Sans SC",
"Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
/* 等宽字体:优先 Cascadia(Windows 自带真粗体,不会有伪粗体的糊边),
中文注释用思源等宽 / 更纱黑体等宽,代码块里中英混排才对得齐 */
--mono: "Cascadia Mono", "Cascadia Code", ui-monospace, "SFMono-Regular",
"SF Mono", Menlo, Consolas, "JetBrains Mono", "Sarasa Mono SC",
"Noto Sans Mono CJK SC", "Source Code Pro", "Liberation Mono", monospace;
--wrap-max: 1680px;
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 8px 24px rgba(16, 24, 40, .06);
--shadow-lg: 0 2px 6px rgba(16, 24, 40, .07), 0 18px 44px rgba(16, 24, 40, .10);
--focus: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* 深色配色:手动切换时优先,其次跟随系统 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #101315;
--bg-grad: radial-gradient(1200px 600px at 15% -10%, #16241d 0%, transparent 60%),
radial-gradient(900px 500px at 95% 0%, #17202b 0%, transparent 55%);
--panel: #191d21;
--panel-2: #1e2327;
--border: #2a3035;
--border-strong: #394147;
--text: #e7eaec;
--text-soft: #c3cad0;
--muted: #98a1ab;
--accent: #7fc9a3;
--on-accent: #0e1a14;
--accent-hover: #97d8b6;
--accent-soft: #1c2a23;
--accent-line: #35543f;
--warn: #e0a86a;
--warn-soft: #2d2318;
--code-bg: #1d2226;
--code-head: #23282d;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 8px 24px rgba(0, 0, 0, .30);
--shadow-lg: 0 2px 8px rgba(0, 0, 0, .40), 0 18px 44px rgba(0, 0, 0, .45);
--focus: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #101315;
--bg-grad: radial-gradient(1200px 600px at 15% -10%, #16241d 0%, transparent 60%),
radial-gradient(900px 500px at 95% 0%, #17202b 0%, transparent 55%);
--panel: #191d21;
--panel-2: #1e2327;
--border: #2a3035;
--border-strong: #394147;
--text: #e7eaec;
--text-soft: #c3cad0;
--muted: #98a1ab;
--accent: #7fc9a3;
--on-accent: #0e1a14;
--accent-hover: #97d8b6;
--accent-soft: #1c2a23;
--accent-line: #35543f;
--warn: #e0a86a;
--warn-soft: #2d2318;
--code-bg: #1d2226;
--code-head: #23282d;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 8px 24px rgba(0, 0, 0, .30);
--shadow-lg: 0 2px 8px rgba(0, 0, 0, .40), 0 18px 44px rgba(0, 0, 0, .45);
--focus: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
* { box-sizing: border-box; }
/* 细一点的滚动条,页面更干净 */
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 6px;
border: 3px solid transparent; background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: content-box; }
html {
scroll-behavior: smooth;
scroll-padding-top: 5.5rem;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg-grad), var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: var(--sans);
line-height: 1.7;
font-size: var(--fs-body);
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
font-optical-sizing: auto;
overflow-wrap: break-word;
}
/* 标题:统一用标题字体;字距保持默认,避免中文标题的字被挤在一起 */
h1, h2, h3, h4 {
line-height: 1.25;
font-family: var(--display);
font-optical-sizing: auto;
font-weight: 700;
text-wrap: balance;
}
/* 正文段落避免出现孤字(一行只剩一两个字) */
p { text-wrap: pretty; }
/* 键盘焦点统一可见,鼠标点击则不显示焦点框 */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
:focus:not(:focus-visible) { outline: none; }
/* ---------- 版心 ---------- */
.wrap {
width: 100%;
max-width: var(--wrap-max);
margin: 0 auto;
padding: 0 clamp(1rem, 3vw, 3rem);
}
.skip {
position: absolute; left: -9999px; top: 0;
background: var(--panel); color: var(--text);
padding: .6rem 1rem; z-index: 99;
border-radius: var(--radius); box-shadow: var(--shadow);
}
.skip:focus { left: 1rem; top: 1rem; }
/* ---------- 顶栏 ---------- */
.site-head {
position: sticky; top: 0; z-index: 30;
background: color-mix(in srgb, var(--panel) 82%, transparent);
backdrop-filter: saturate(180%) blur(14px);
-webkit-backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.head-inner {
display: flex; align-items: center; gap: clamp(.75rem, 1.6vw, 1.75rem);
min-height: 3.75rem;
flex-wrap: wrap;
}
.brand {
display: flex; align-items: center; gap: .5rem;
text-decoration: none; color: var(--text);
border-radius: 8px;
}
.brand-mark {
width: 28px; height: 28px;
border-radius: 6px; object-fit: cover;
display: block; flex: none;
}
.brand-text {
font-family: var(--display); font-weight: 700;
font-size: var(--fs-lg); letter-spacing: -.015em;
}
.brand-version {
color: var(--muted); font-size: var(--fs-xs); font-family: var(--mono);
font-variant-numeric: tabular-nums;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: 999px; padding: .05rem .45rem;
}
.site-nav { display: flex; gap: clamp(.7rem, 1.4vw, 1.4rem); }
.site-nav a {
color: var(--muted); text-decoration: none; font-size: var(--fs-md);
padding: .35rem 0; border-bottom: 2px solid transparent;
white-space: nowrap; transition: color .15s, border-color .15s;
}
.site-nav a:hover { color: var(--text); }
.site-nav a.is-active {
color: var(--accent); border-bottom-color: var(--accent); font-weight: 600;
}
/* 顶栏右侧的操作区 */
.head-actions {
margin-left: auto;
display: flex; align-items: center; gap: .5rem;
min-width: 0;
}
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 2.25rem; height: 2.25rem; flex: 0 0 auto;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); color: var(--muted);
cursor: pointer; font-size: var(--fs-md); line-height: 1;
transition: color .15s, border-color .15s, background .15s;
}
.icon-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
/* 语言切换:链到另一种语言的同一个页面 */
.lang-switch {
display: inline-flex; align-items: center; justify-content: center;
height: 2.25rem; padding: 0 .7rem; flex: 0 0 auto;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); color: var(--muted);
text-decoration: none; font-size: var(--fs-sm); line-height: 1;
white-space: nowrap;
transition: color .15s, border-color .15s, background .15s;
}
.lang-switch:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
/* 主题切换:三个状态用 data-theme 属性控制,图标由 JS 切换 */
.theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; }
:root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
}
/* 窄屏的菜单按钮(宽屏隐藏) */
.nav-toggle {
display: none; margin-left: auto;
width: 2.5rem; height: 2.25rem; padding: .55rem .5rem;
background: transparent; border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
flex-direction: column; justify-content: space-between;
}
.nav-toggle span {
display: block; height: 2px; background: var(--text);
border-radius: 2px; transition: transform .2s, opacity .2s;
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* ---------- 首页 ---------- */
.hero {
display: grid; gap: clamp(1.5rem, 3vw, 3rem);
grid-template-columns: minmax(0, 1fr);
align-items: start;
padding: clamp(2.25rem, 5vw, 4.25rem) 0 clamp(1.5rem, 3vw, 2.5rem);
}
.hero-text { min-width: 0; }
.eyebrow {
font-family: var(--mono); text-transform: uppercase; letter-spacing: .16em;
font-size: var(--fs-xs); color: var(--accent); margin: 0 0 .7rem; font-weight: 600;
}
/* 主标题:换用标题字体(真粗体,不会有等宽字体的伪粗体糊边),
字距收紧、行高压紧,让大字号更利落 */
.hero h1 {
font-family: var(--display);
font-size: var(--fs-display);
font-weight: 800;
letter-spacing: -.035em;
line-height: 1.04;
margin: 0 0 .55rem;
text-wrap: balance;
}
.lead {
font-size: var(--fs-lead); color: var(--text-soft);
max-width: 46rem; margin: 0 0 1.4rem;
}
/* 安装命令 */
.install {
display: inline-flex; align-items: stretch; max-width: 100%;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
box-shadow: var(--shadow);
}
.install code {
display: block; padding: .7rem 1.1rem; font-family: var(--mono); font-size: var(--fs-md);
overflow-x: auto; white-space: nowrap;
}
.install .copy {
border: 0; border-left: 1px solid var(--border); background: var(--code-bg);
color: var(--muted); font-family: inherit; font-size: var(--fs-xs);
padding: 0 .85rem; cursor: pointer; white-space: nowrap;
transition: color .15s, background .15s;
}
.install .copy:hover { color: var(--accent); background: var(--accent-soft); }
.hero-meta { color: var(--muted); font-size: var(--fs-md); margin: .9rem 0 0; }
.hero-meta strong { color: var(--text); font-variant-numeric: tabular-nums; }
.hero-links { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.4rem 0 0; }
.btn {
display: inline-block; text-decoration: none; font-size: var(--fs-md); font-weight: 600;
padding: .5rem 1.05rem; border-radius: var(--radius);
background: var(--accent); color: var(--on-accent); border: 1px solid transparent;
transition: background .15s, transform .1s, box-shadow .15s;
}
.btn:hover { background: var(--accent-hover); box-shadow: var(--shadow); }
.btn:active { transform: translateY(1px); }
.btn-ghost { background: transparent; color: var(--text); border-color: var(--border); }
.btn-ghost:hover {
border-color: var(--accent); color: var(--accent);
background: var(--accent-soft); box-shadow: none;
}
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: 1rem; margin: 0 0 clamp(2rem, 4vw, 3rem);
}
.stat {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.1rem; text-align: center;
transition: border-color .15s, transform .15s, box-shadow .15s;
}
.stat:hover { border-color: var(--accent-line); transform: translateY(-2px); box-shadow: var(--shadow); }
.stat-value {
display: block; font-size: var(--fs-stat);
font-weight: 700; font-family: var(--display); color: var(--accent);
font-variant-numeric: tabular-nums; /* 数字等宽,位数变化时不跳动 */
}
.stat-label { display: block; color: var(--muted); font-size: var(--fs-xs); margin-top: .15rem; }
.section { margin: clamp(2rem, 4vw, 3rem) 0; }
.section h2 { font-size: var(--fs-xl); margin-bottom: 1rem; }
.section-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;
}
.section-head h2 { margin: 0; }
.more { color: var(--accent); text-decoration: none; font-size: var(--fs-md); white-space: nowrap; }
.more:hover { text-decoration: underline; }
.module-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr)); gap: .7rem;
}
.module-card {
display: flex; flex-direction: column; gap: .2rem;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: .85rem 1rem;
text-decoration: none; color: var(--text);
transition: border-color .15s, transform .15s, box-shadow .15s;
}
.module-card:hover {
border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow);
}
.module-name { font-family: var(--mono); font-size: var(--fs-md); overflow-wrap: anywhere; }
.module-count { color: var(--muted); font-size: var(--fs-xs); }
.module-desc { color: var(--muted); font-size: var(--fs-xs); }
/* 双栏面板 */
.split {
display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
}
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.2rem 1.4rem;
}
.panel h2 { font-size: var(--fs-lg); margin: 0 0 .7rem; }
.panel p { margin: .5rem 0; font-size: var(--fs-sm); }
.panel .params { margin: 0; }
.panel .params td:first-child { color: var(--muted); width: 34%; }
/* ---------- 通用元素 ---------- */
.page-head { padding: clamp(2rem, 4vw, 3rem) 0 1rem; }
.page-head h1 { font-size: var(--fs-title); margin: 0 0 .5rem; }
.page-head .lead { margin-bottom: 0; }
.muted { color: var(--muted); }
/* 代码:等宽字体统一在这里设定;
关掉连字,避免 -> 之类的符号被渲染成箭头,读代码时产生歧义 */
code, pre, kbd, samp {
font-family: var(--mono);
font-variant-ligatures: none;
font-feature-settings: "kern" 1, "liga" 0, "calt" 0;
}
code { font-size: .9em; }
/* 代码块:右上角带复制按钮 */
pre.code {
position: relative;
background: var(--code-bg); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1rem 1.1rem;
overflow-x: auto; font-size: var(--fs-sm); line-height: 1.65;
tab-size: 4;
}
pre.code code { font-size: inherit; }
.code-copy {
position: absolute; top: .4rem; right: .4rem;
border: 1px solid var(--border); border-radius: 6px;
background: var(--panel); color: var(--muted);
font-family: inherit; font-size: var(--fs-xs); line-height: 1;
padding: .3rem .5rem; cursor: pointer; opacity: 0;
transition: opacity .15s, color .15s, border-color .15s;
}
pre.code:hover .code-copy, .code-copy:focus-visible { opacity: 1; }
.code-copy:hover { color: var(--accent); border-color: var(--accent); }
a { color: var(--accent); }
/* ---------- API 页 ---------- */
.api-layout {
display: grid; gap: clamp(1.5rem, 2.5vw, 2.5rem);
grid-template-columns: 20rem minmax(0, 1fr);
align-items: start;
padding-bottom: 3rem;
}
.api-nav {
position: sticky; top: 5rem; align-self: start;
max-height: calc(100vh - 7rem); overflow-y: auto; overflow-x: hidden;
min-width: 0;
}
.api-nav-title {
font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em;
color: var(--muted); margin: 0 0 .6rem;
}
.api-nav ul { list-style: none; margin: 0; padding: 0; }
.api-nav li a {
display: flex; justify-content: space-between; align-items: baseline; gap: .5rem;
padding: .3rem .5rem; border-radius: 6px;
color: var(--text); text-decoration: none; font-size: var(--fs-xs); font-family: var(--mono);
letter-spacing: -.01em; /* 模块名较长,收一点字距避免中途折行 */
border-left: 2px solid transparent;
}
.api-nav li a .nav-name {
min-width: 0; flex: 1 1 auto;
overflow-wrap: anywhere; word-break: break-word;
}
.api-nav li a:hover { background: var(--accent-soft); }
.api-nav li a.is-active {
background: var(--accent-soft); color: var(--accent);
border-left-color: var(--accent); font-weight: 600;
}
.nav-count { color: var(--muted); font-size: var(--fs-2xs); flex-shrink: 0; }
/* 选中态的底色是 --accent-soft,跟 --muted 的对比度不够 4.5:1,
这里改用 --accent(同一个强调色,深浅两套配色都够)。 */
.api-nav li a.is-active .nav-count,
.tut-nav li a.is-active .nav-count { color: var(--accent); }
.api-body { min-width: 0; container-type: inline-size; }
/* API 页顶部的筛选框 */
.api-filter {
display: flex; align-items: center; gap: .5rem;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0 .6rem; margin-bottom: 1.2rem;
}
.api-filter:focus-within { border-color: var(--accent); box-shadow: var(--focus); }
.api-filter input {
flex: 1 1 auto; min-width: 0; padding: .5rem 0;
border: 0; background: transparent; color: var(--text);
font-size: var(--fs-md); font-family: inherit;
}
.api-filter input:focus { outline: none; }
.api-filter .filter-count { color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
.api-filter .filter-clear {
border: 0; background: transparent; color: var(--muted);
cursor: pointer; font-size: var(--fs-xs); padding: .2rem .35rem; border-radius: 4px;
}
.api-filter .filter-clear:hover { color: var(--accent); background: var(--accent-soft); }
.module { padding-top: 1rem; margin-bottom: clamp(2.5rem, 5vw, 4rem); }
.module-title {
font-family: var(--mono); font-size: var(--fs-module); margin: 0 0 .3rem;
padding-bottom: .5rem; border-bottom: 2px solid var(--accent);
overflow-wrap: anywhere;
}
.module-meta { color: var(--muted); font-size: var(--fs-sm); margin: .5rem 0 1.5rem; overflow-wrap: anywhere; }
.module-meta code { overflow-wrap: anywhere; }
.group-title {
font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em;
color: var(--muted); margin: 2rem 0 .8rem; font-weight: 700;
}
/* 宽屏:函数卡片排成多列 */
.func-grid {
display: grid; gap: .8rem;
grid-template-columns: repeat(auto-fill, minmax(min(30rem, 100%), 1fr));
align-items: start;
}
/* 内容区足够宽时,一行只放一个卡片,卡片更宽 */
@container (min-width: 58rem) {
.func-grid { grid-template-columns: minmax(0, 1fr); }
}
.func {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.1rem 1.25rem;
min-width: 0;
scroll-margin-top: 6rem;
transition: border-color .15s, box-shadow .15s;
}
.func:target { border-color: var(--accent); box-shadow: var(--shadow); }
.func.is-hidden { display: none; }
.func-section {
margin: 0 0 .4rem; color: var(--muted); font-size: var(--fs-xs);
overflow-wrap: anywhere;
}
.func h3 {
margin: 0 0 .4rem; display: flex; align-items: center; gap: .6rem;
flex-wrap: wrap;
}
.func-name { font-size: var(--fs-lg); font-weight: 600; overflow-wrap: anywhere; }
/* 卡片右上角的「复制链接」按钮 */
.anchor-btn {
margin-left: auto; flex: 0 0 auto;
border: 1px solid transparent; border-radius: 6px;
background: transparent; color: var(--muted);
cursor: pointer; font-size: var(--fs-2xs); line-height: 1;
padding: .25rem .45rem; opacity: .55;
transition: opacity .15s, color .15s, background .15s, border-color .15s;
}
.func:hover .anchor-btn, .anchor-btn:focus-visible { opacity: 1; }
.anchor-btn:hover { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); }
.sig { margin: 0 0 .6rem; }
.sig code { font-size: var(--fs-sm); color: var(--text); word-break: break-word; }
.desc { margin: 0 0 .8rem; color: var(--text); font-size: var(--fs-md); }
.calls { color: var(--muted); font-size: var(--fs-xs); margin: .5rem 0 0; overflow-wrap: anywhere; }
.docstring p { font-size: var(--fs-sm); color: var(--text-soft); margin: .4rem 0; }
.badge {
font-size: var(--fs-2xs); padding: .1rem .45rem; border-radius: 999px;
background: var(--accent-soft); color: var(--accent); font-family: var(--mono);
border: 1px solid transparent;
}
.badge-warn { background: var(--warn-soft); color: var(--warn); }
.badge-muted { background: var(--code-bg); color: var(--muted); }
.params { width: 100%; border-collapse: collapse; margin: .8rem 0; font-size: var(--fs-sm); table-layout: fixed; }
.params th, .params td {
text-align: left; padding: .35rem .6rem;
border-bottom: 1px solid var(--border); vertical-align: top;
overflow-wrap: anywhere;
}
.params th { color: var(--muted); font-weight: 600; font-size: var(--fs-xs); }
.params th:nth-child(1) { width: 26%; }
.params th:nth-child(2) { width: 30%; }
.params th:nth-child(3) { width: 44%; }
.params td code { overflow-wrap: anywhere; }
.param-default { color: var(--warn); }
.param-type { color: var(--muted); font-size: var(--fs-xs); }
.source { margin-top: .7rem; }
.source summary {
cursor: pointer; color: var(--muted); font-size: var(--fs-xs);
display: inline-block; padding: .1rem 0;
}
.source summary:hover { color: var(--accent); }
.source pre { margin-top: .6rem; }
.class-block {
border-left: 3px solid var(--accent); padding-left: 1rem; margin-bottom: 1.5rem;
}
.class-name { font-family: var(--mono); }
/* 模块内的函数速查 */
.module-index {
list-style: none; margin: 0 0 1.4rem; padding: .7rem .9rem;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: var(--radius);
display: flex; flex-wrap: wrap; gap: .35rem .5rem;
}
.module-index a {
font-family: var(--mono); font-size: var(--fs-xs); text-decoration: none;
color: var(--text-soft); background: var(--panel);
border: 1px solid var(--border); border-radius: 999px;
padding: .1rem .55rem; transition: color .15s, border-color .15s, background .15s;
}
.module-index a:hover {
color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft);
}
/* ---------- 关于页(README 渲染结果) ---------- */
/* 「安装」「关于」两页与首页用同一个版心宽度,
区别只在于正文铺满版心,而不是缩在中间一小块 */
.doc-layout {
display: grid; gap: clamp(1.5rem, 3vw, 3rem);
grid-template-columns: 18rem minmax(0, 1fr);
align-items: start;
padding-bottom: 3rem;
}
.doc-layout.doc-layout-flat { grid-template-columns: minmax(0, 1fr); }
.doc-layout.doc-layout-flat .markdown { grid-column: 1; }
.toc {
grid-column: 1; grid-row: 1;
position: sticky; top: 5rem; align-self: start;
max-height: calc(100vh - 7rem); overflow-y: auto;
border-right: 1px solid var(--border); padding-right: 1rem;
min-width: 0;
}
.toc-title {
font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em;
color: var(--muted); margin: 0 0 .6rem;
}
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc a {
display: block; padding: .22rem .4rem; border-radius: 6px;
color: var(--muted); text-decoration: none; font-size: var(--fs-sm);
overflow-wrap: anywhere;
}
.toc a:hover { color: var(--text); background: var(--accent-soft); }
.toc a.is-active { color: var(--accent); font-weight: 600; background: var(--accent-soft); }
.toc-l3 a { padding-left: 1.3rem; font-size: var(--fs-xs); }
/* 正文铺满版心:代码块、表格、列表和段落都跟着版心走,
不再缩在中间一小块;段落不再限制行宽,文字一直排到版心右缘 */
.markdown { grid-column: 2; grid-row: 1; max-width: none; min-width: 0; }
.markdown p, .markdown blockquote { max-width: none; }
.markdown p > img { max-width: 100%; }
.markdown h2 {
font-size: var(--fs-xl); margin: 2.2rem 0 .8rem;
padding-bottom: .4rem; border-bottom: 1px solid var(--border);
scroll-margin-top: 6rem;
}
.markdown h3 { font-size: var(--fs-lg); margin: 1.6rem 0 .6rem; scroll-margin-top: 6rem; }
.markdown h4, .markdown h5, .markdown h6 { font-size: var(--fs-body); margin: 1.2rem 0 .5rem; }
.markdown > h2:first-child { margin-top: 0; }
.markdown p { margin: .8rem 0; }
.markdown ul, .markdown ol { margin: .8rem 0; padding-left: 1.4rem; }
.markdown li { margin: .3rem 0; }
.markdown li p { margin: .2rem 0; }
.markdown hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
.markdown blockquote {
margin: 1rem 0; padding: .3rem 1rem;
border-left: 3px solid var(--accent); background: var(--panel);
border-radius: 0 var(--radius) var(--radius) 0;
}
.markdown blockquote p { margin: .4rem 0; }
.markdown code {
background: var(--code-bg); border: 1px solid var(--border);
border-radius: 4px; padding: .05rem .3rem;
}
.markdown pre.code code { border: 0; padding: 0; background: none; }
.markdown pre.code { margin: 1rem 0; }
.markdown .table-wrap { overflow-x: auto; margin: 1rem 0; }
.markdown .md-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
.markdown .md-table th, .markdown .md-table td {
border: 1px solid var(--border); padding: .45rem .7rem; text-align: left;
}
.markdown .md-table th { background: var(--code-bg); font-weight: 600; }
.markdown img { max-width: 100%; height: auto; border-radius: var(--radius); }
/* ---------- 教程页 ---------- */
/* 与 API 页一样:左侧分组目录,右侧正文 */
.tut-layout {
display: grid; gap: clamp(1.5rem, 3vw, 3rem);
grid-template-columns: 18rem minmax(0, 1fr);
align-items: start;
padding-bottom: 3rem;
}
.tut-body { min-width: 0; }
/* 分组目录:名称与数量分开排 */
.tut-nav li a { display: flex; align-items: baseline; gap: .6rem; }
.tut-nav .nav-count { margin-left: auto; }
.tut-group { margin-bottom: clamp(2.5rem, 5vw, 4rem); scroll-margin-top: 6rem; }
.tut-group-title {
font-size: clamp(1.3rem, 2.4vw, 1.6rem); margin: 0 0 .3rem;
padding-bottom: .5rem; border-bottom: 2px solid var(--accent);
}
.tut-group-sub { color: var(--muted); font-size: .85rem; margin: .4rem 0 1.2rem; }
.tut-cards {
display: grid; gap: .9rem;
grid-template-columns: repeat(auto-fill, minmax(min(28rem, 100%), 1fr));
align-items: start;
}
.tut-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.1rem 1.25rem;
min-width: 0; scroll-margin-top: 6rem;
transition: border-color .15s, box-shadow .15s;
}
.tut-card:target { border-color: var(--accent); box-shadow: var(--shadow); }
.tut-card-head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
.tut-title { margin: 0 0 .5rem; font-size: 1.02rem; font-weight: 600; overflow-wrap: anywhere; }
.tut-lines { color: var(--muted); font-size: .78rem; margin-left: auto; }
.tut-desc { margin: 0 0 .7rem; font-size: .93rem; overflow-wrap: anywhere; }
.tut-links { margin: .6rem 0 0; font-size: .85rem; }
.tut-links a { color: var(--accent); text-decoration: none; }
.tut-links a:hover { text-decoration: underline; }
/* ---------- 页脚 ---------- */
.site-foot {
border-top: 1px solid var(--border); margin-top: 4rem;
padding: 2rem 0 2.5rem; color: var(--muted); font-size: var(--fs-sm);
background: var(--panel);
}
.foot-inner {
display: flex; gap: 2rem; justify-content: space-between;
align-items: flex-start; flex-wrap: wrap;
}
.site-foot p { margin: .25rem 0; max-width: 60rem; }
.foot-meta { font-size: var(--fs-xs); }
.foot-nav { display: flex; gap: 1rem; flex-wrap: wrap; }
.foot-nav a { color: var(--muted); text-decoration: none; }
.foot-nav a:hover { color: var(--accent); }
/* ---------- 回到顶部 ---------- */
.to-top {
position: fixed; right: clamp(1rem, 3vw, 2rem); bottom: clamp(1rem, 3vw, 2rem);
width: 2.75rem; height: 2.75rem; z-index: 25;
display: inline-flex; align-items: center; justify-content: center;
background: var(--panel); color: var(--muted);
border: 1px solid var(--border); border-radius: 50%;
box-shadow: var(--shadow); cursor: pointer;
opacity: 0; visibility: hidden; transform: translateY(8px);
transition: opacity .2s, transform .2s, visibility .2s, color .15s, border-color .15s;
}
.to-top.is-visible { opacity: 1; visibility: visible; transform: none; }
.to-top:hover { color: var(--accent); border-color: var(--accent); }
/* ---------- 响应式 ---------- */
@media (max-width: 1400px) {
.api-layout { grid-template-columns: 17rem minmax(0, 1fr); }
.doc-layout { grid-template-columns: 15rem minmax(0, 1fr); }
.tut-layout { grid-template-columns: 15rem minmax(0, 1fr); }
}
@media (max-width: 1100px) {
.hero { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 1080px) {
.api-layout { grid-template-columns: minmax(0, 1fr); }
.api-nav {
position: static; max-height: none; overflow: visible;
border: 1px solid var(--border); border-radius: var(--radius);
padding: .8rem 1rem; background: var(--panel);
}
.api-nav ul {
display: flex; flex-wrap: nowrap; gap: .4rem;
overflow-x: auto; padding-bottom: .3rem;
scrollbar-width: thin;
}
.api-nav li { flex: 0 0 auto; }
.api-nav li a {
border: 1px solid var(--border); border-radius: 999px; padding: .25rem .7rem;
white-space: nowrap;
}
.api-nav li a .nav-name { flex: 0 1 auto; }
.doc-layout { grid-template-columns: minmax(0, 1fr); }
.tut-layout { grid-template-columns: minmax(0, 1fr); }
.toc, .markdown { grid-column: 1; grid-row: auto; }
.toc {
position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--border);
padding: 0 0 .8rem;
}
.toc ul { display: flex; flex-wrap: wrap; gap: .35rem; }
.toc a { border: 1px solid var(--border); border-radius: 999px; padding: .2rem .7rem; }
.toc-l3 a { padding-left: .7rem; }
}
@media (max-width: 900px) {
.head-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: .55rem .75rem;
padding: .6rem 0;
}
.brand { grid-column: 1; grid-row: 1; }
.head-actions {
grid-column: 2; grid-row: 1;
margin-left: 0;
}
.nav-toggle { grid-column: 3; grid-row: 1; display: flex; margin-left: 0; }
.site-nav {
grid-column: 1 / -1; grid-row: 2;
flex-direction: column; gap: 0;
max-height: 0; overflow: hidden; transition: max-height .25s ease;
border-top: 1px solid transparent;
}
.site-nav.is-open { max-height: 20rem; overflow-y: auto; border-top-color: var(--border); }
.site-nav a { padding: .7rem .25rem; border-bottom: 1px solid var(--border); }
.site-nav a.is-active { border-bottom-color: var(--border); color: var(--accent); }
}
@media (max-width: 640px) {
body { font-size: .9375rem; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.module-grid { grid-template-columns: minmax(0, 1fr); }
.func-grid { grid-template-columns: minmax(0, 1fr); }
.tut-cards { grid-template-columns: minmax(0, 1fr); }
.install { width: 100%; }
.install code { font-size: var(--fs-xs); }
.hero-links .btn { flex: 1 1 auto; text-align: center; }
.page-head { padding-top: 1.6rem; }
.foot-inner { flex-direction: column; gap: 1rem; }
.to-top { right: 1rem; bottom: 1rem; }
}
@media (max-width: 420px) {
.stats { grid-template-columns: minmax(0, 1fr); }
}
/* ---------- 打印 ---------- */
@media print {
.site-head, .site-foot, .to-top, .code-copy, .anchor-btn,
.api-filter, .nav-toggle { display: none !important; }
body { background: #fff; color: #000; font-size: 12pt; }
.func, .panel, .stat { break-inside: avoid; border-color: #ccc; }
pre.code { white-space: pre-wrap; word-break: break-word; }
a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #555; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { transition: none !important; animation: none !important; }
}
/* 触摸设备没有悬停,复制按钮要一直显示 */
@media (hover: none) {
.code-copy { opacity: 1; }
.anchor-btn { opacity: 1; }
}

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<meta name="description" content="{{description}}">
<meta name="color-scheme" content="light dark">
<link rel="alternate" hreflang="zh-CN" href="{{alt_zh}}">
<link rel="alternate" hreflang="en" href="{{alt_en}}">
<link rel="alternate" hreflang="x-default" href="{{alt_default}}">
<link rel="canonical" href="{{canonical}}">
<link rel="icon" type="image/jpeg" href="static/icon.jpg">
<link rel="apple-touch-icon" href="static/icon.jpg">
<link rel="stylesheet" href="static/style.css">
<script>
// 恢复用户上次选的主题(尽早执行,避免刷新时闪一下白屏)
try {
var t = localStorage.getItem("guan-theme");
if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
</script>
</head>
<body>
<a class="skip" href="#main">{{skip_link}}</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="brand" href="index.html">
<img class="brand-mark" src="static/icon.jpg" alt="" width="28" height="28">
<span class="brand-text">{{package_name}}</span>
<span class="brand-version">v{{version}}</span>
</a>
<nav class="site-nav" id="site-nav">
{{nav_items}}
</nav>
<div class="head-actions">
<a class="lang-switch" id="lang-switch" href="{{lang_switch}}" hreflang="{{lang_switch_hreflang}}"
title="{{lang_switch_title}}" aria-label="{{lang_switch_aria}}">{{lang_switch_label}}</a>
<button class="icon-btn theme-toggle" id="theme-toggle" type="button"
aria-label="{{theme_label}}" title="{{theme_label}}">
<span class="icon-moon" aria-hidden="true">☾</span>
<span class="icon-sun" aria-hidden="true">☀</span>
</button>
</div>
<button class="nav-toggle" id="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav" aria-label="{{menu_label}}">
<span></span><span></span><span></span>
</button>
</div>
</header>
<main id="main" class="wrap">
{{content}}
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<div>
<p class="foot-meta">{{foot_meta}}</p>
</div>
<nav class="foot-nav">
{{foot_nav_items}}
</nav>
</div>
</footer>
<button class="to-top" id="to-top" type="button" aria-label="{{to_top_label}}" title="{{to_top_label}}">↑</button>
<script>window.SITE_I18N = {{site_i18n}};</script>
<script src="static/site.js"></script>
{{extra_js}}
</body>
</html>

272
website/tutorials.py Normal file
View File

@@ -0,0 +1,272 @@
"""读取 tutorials/ 目录,为网站提供「教程」页的内容。
教程内容不手工维护:分组、标题、说明、依赖都从 tutorials/ 下的脚本
与其 README.md 里读出来。仓库里新增一个示例脚本,网站自动跟着更新。
信息来自两处,互相补充:
* 每个脚本开头的注释——标题、来源链接、一句话说明
* tutorials/README.md 里的表格——更详细的说明与依赖说明
两处都没有时,退回到按文件名与 import 推断,保证页面不为空。
"""
from __future__ import annotations
import re
from pathlib import Path
# 「# 来源 / Source: https://...」
SOURCE_RE = re.compile(r"来源\s*/\s*Source\s*[::]\s*(\S+)")
# README 表格里的一行:| `01_ai_chat.py` | 说明 | 依赖 |
TABLE_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*$")
# 脚本里的 import,用来猜依赖
IMPORT_RE = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE)
# 根目录下的脚本(不在子目录里)单独归为一组
ROOT_GROUP_TITLE = "开始使用"
ROOT_GROUP_SUBTITLE = "Getting started"
def _read_text(path: Path) -> str:
"""读文本文件,编码不对时退回 latin-1,绝不抛异常。"""
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
try:
return path.read_text(encoding="gbk")
except (OSError, UnicodeDecodeError):
return ""
def _split_heading(heading: str) -> tuple[str, str]:
"""把「一、日常场景 / Daily scenarios」拆成中文标题与英文副标题。"""
parts = re.split(r"\s*/\s*", heading.strip(), maxsplit=1)
title = parts[0].strip()
subtitle = parts[1].strip() if len(parts) > 1 else ""
return title, subtitle
def parse_readme(readme_path: Path) -> dict:
"""解析 tutorials/README.md。
返回:
files 文件名 -> {desc, req, section}
sections [(标题, 副标题, [文件名...])]
"""
files: dict[str, dict] = {}
sections: list[tuple[str, str, list[str]]] = []
if not readme_path.exists():
return {"files": files, "sections": sections}
section_title = ""
section_subtitle = ""
section_files: list[str] = []
def flush() -> None:
if section_title or section_files:
sections.append((section_title, section_subtitle, list(section_files)))
for line in _read_text(readme_path).splitlines():
if line.startswith("## "):
flush()
section_title, section_subtitle = _split_heading(line[3:])
section_files = []
continue
match = TABLE_ROW_RE.match(line.strip())
if not match:
continue
name, desc, req = match.groups()
files[name] = {
"desc": desc.strip(),
"req": req.strip(),
"section": section_title,
}
section_files.append(name)
flush()
return {"files": files, "sections": sections}
def parse_script(path: Path) -> dict:
"""读脚本开头的注释块。
约定:文件开头连续的注释里,第一行是标题,含「来源 / Source」的行是出处,
其余行是补充说明。注释块在遇到第一行代码时结束。
"""
title = ""
source = ""
notes: list[str] = []
for line in _read_text(path).splitlines():
stripped = line.strip()
if not stripped:
continue
if not stripped.startswith("#"):
break
text = stripped.lstrip("#").strip()
if not text:
continue
match = SOURCE_RE.search(text)
if match:
source = match.group(1)
continue
if not title:
title = text
continue
notes.append(text)
return {"title": title, "source": source, "notes": notes}
def detect_requirements(code: str) -> list[str]:
"""从 import 语句推断依赖(README 里没写时用)。"""
names = []
for name in IMPORT_RE.findall(code):
if name in ("guan", "os", "sys", "time", "json", "re", "math", "cmath", "random"):
continue
if name not in names:
names.append(name)
return names
def _short_title(description: str) -> str:
"""从一段说明里取出适合当标题的短句。
README 的说明常常是「try except 简写:`@guan.try_decorator`」这样,
标题取第一个短句就够了,剩下的留给说明。
"""
text = description.strip()
if not text:
return ""
head = re.split(r"[::,,。;;]", text, maxsplit=1)[0].strip()
head = head.replace("`", "").strip()
return head or text.replace("`", "").strip()
def _requirements_from_cell(cell: str) -> list[str]:
"""把 README 表格里的依赖单元格拆成列表。
只按逗号、顿号、分号拆——单元格里的斜杠常常是路径或模型名的一部分,
拆开反而看不懂。
"""
if not cell or cell.strip() in {"-", "—", "无", "N/A"}:
return []
parts = re.split(r"[,,、;;]", cell)
return [part.strip(" `·") for part in parts if part.strip(" `·")]
def collect(tutorials_dir: Path) -> dict:
"""收集 tutorials 目录下的全部示例脚本。
返回值可以直接放进 api.json,也可以直接渲染成页面。
"""
tutorials_dir = Path(tutorials_dir).resolve()
if not tutorials_dir.is_dir():
return {"dir": tutorials_dir.name, "readme": "", "groups": [], "files": [], "count": 0}
readme = tutorials_dir / "README.md"
readme_info = parse_readme(readme)
scripts = sorted(
path for path in tutorials_dir.rglob("*.py") if path.is_file()
)
groups: dict[str, dict] = {}
files: list[dict] = []
for path in scripts:
relative = path.relative_to(tutorials_dir)
group_key = relative.parts[0] if len(relative.parts) > 1 else ""
info = parse_script(path)
code = _read_text(path)
entry = readme_info["files"].get(path.name, {})
# 标题与说明都来自脚本自身:首行注释是标题,其余注释是说明。
# README 的表格只用来补标题与依赖,不再重复它写的那段说明。
title = info["title"] or _short_title(entry.get("desc", "")) or relative.name
description = ";".join(info["notes"])
requirements = _requirements_from_cell(entry.get("req", ""))
if not requirements:
requirements = detect_requirements(code)
# 链接用相对仓库根目录的路径,便于站点里直接下载脚本
repo_relative = (Path(tutorials_dir.name) / relative).as_posix()
anchor = f"{group_key or 'root'}-{relative.stem}".replace("_", "-")
item = {
"key": relative.as_posix(),
"path": repo_relative,
"group": group_key,
"title": title,
"description": description,
"requirements": requirements,
"source": info["source"],
"anchor": anchor,
"lines": len(code.splitlines()),
"code": code,
}
files.append(item)
group = groups.setdefault(
group_key,
{
"key": group_key,
"title": ROOT_GROUP_TITLE if group_key == "" else group_key.replace("_", " "),
"subtitle": ROOT_GROUP_SUBTITLE if group_key == "" else "",
"anchor": f"group-{group_key or 'root'}",
"files": [],
},
)
group["files"].append(item)
# 分组标题优先用 README 里的章节标题
for key, group in groups.items():
if not key:
continue
best: tuple[str, str, int] | None = None
for title, subtitle, names in readme_info["sections"]:
overlap = sum(
1 for item in group["files"] if Path(item["key"]).name in names
)
if overlap and (best is None or overlap > best[2]):
best = (title, subtitle, overlap)
if best:
group["title"], group["subtitle"] = best[0], best[1]
ordered = sorted(
groups.values(),
key=lambda group: (group["key"] != "", group["title"]),
)
return {
"dir": tutorials_dir.name,
"readme": (tutorials_dir / "README.md").name if readme.exists() else "",
"groups": ordered,
"files": files,
"count": len(files),
}
def without_code(data: dict) -> dict:
"""去掉源码的副本,用于放进 api.json(索引里用不着整段代码)。"""
return {
"dir": data.get("dir", ""),
"count": data.get("count", 0),
"groups": [
{
"key": group["key"],
"title": group["title"],
"subtitle": group.get("subtitle", ""),
"anchor": group["anchor"],
"files": [
{key: value for key, value in item.items() if key != "code"}
for item in group["files"]
],
}
for group in data.get("groups", [])
],
"files": [
{key: value for key, value in item.items() if key != "code"}
for item in data.get("files", [])
],
}