幾天我在面試前端開發同學的時候,有問到關于margin基礎布局相關內容的過程中,發現很多同學基本解釋不清楚,今天剛好有點時間就整理了一篇筆記出來。就以下5點在CSS布局經常會用到的經典布局解決方案。
可以嘗試動手試一試,有什么疑問 !可隨時交流,有問必答 。
margin 縱向重疊(合并)問題
元素垂直排列時,第一個元素的下外邊距與第二個元素的上外邊距會發生合并,合并后的間距就是兩者中最大的那個值。
<style>
.box{
margin-top:10px;/*上外邊距*/
margin-bottom:20px;/*下外邊距*/
height: 20px;
background-color:skyblue;
}
</style>
<body>
<div class="box">item1</div>
<div class="box"></div>
<div class="box"></div>
<div class="box">item4</div>
</body>
答案:
解析:item1 與 item4 之間的間距為 3個下外邊距大小+2個盒子高度=20*3+20*2=100px
<style>
.box{
margin-top:10px;
margin-bottom:20px;
background-color:skyblue;
}
</style>
<body>
<div class="box">item1</div>
<div class="box"></div>
<div class="box"></div>
<div class="box">item4</div>
</body>
答案: item1與item4之間間距為 20px
解析:因為中間兩個box中沒有內容也沒有邊框線,所以外邊距會一直重疊合并,所以最后item1和item4之間距離只有一個下外邊距的大小
margin 穿透問題
當一個元素包含在另一個元素中時,如果父元素沒有設置內邊距或邊框把外邊距分隔開,它們的上或下外邊距也會發生合并。
<style>
body{
margin:0;
padding:0;
}
.container{
width:300px;
height: 300px;
background-color: salmon;
margin-top:100px;/*與瀏覽器頂部的距離*/
border:5px solid blue;
}
.container .item{
width:200px;
height: 200px;
background-color: skyblue;
margin-top:50px;/*因為container中加了border邊框,所以這里的外邊距不會穿透合并*/
}
.container .item .box{
width:100px;
height: 100px;
background-color: bisque;
margin-top:10px;/*item沒有加邊框線,內邊距和其它內容,所以外邊距會發生穿透合并*/
border:5px solid red;
}
</style>
<body>
<div class="container">
<div class="item">
<div class="box"></div>
</div>
</div>
</body>
答案: 100px 、155px、155px
解析:
.container與瀏覽器頂部距離是100px,
.item與瀏覽器頂部距離100px + 5px+50px=155px
.box與瀏覽器頂部距離:100px+5px+50px=155px
margin-left 設置負值,元素向左移動
margin-right 設置負值,自身不受影響,右邊元素向左移動
margin-top設置負值,元素向上移動
margin-bottom 設置負值,自身不受影響,下方元素向上移動
<style>
body{
margin:0;
}
.container{
width:500px;
height:200px;
padding:20px 0px;
border:5px solid #ddd;
margin:0px auto;
}
.container .common{
width:200px;
height: 200px;
float: left;
}
.container .box1{
background-color: skyblue;
/* margin-left:-100px; 元素自身向左移動,右邊的元素也會受影響*/
margin-right:-100px;/*元素自身不受影響,右邊元素向左移動*/
}
.container .box2{
background-color: tomato;
}
</style>
<body>
<div class="container">
<div class="box1 common"></div>
<div class="box2 common"></div>
</div>
</body>
當.container .box1中margin-left:-100px;時,如:圖1
當.container .box1中 margin-right:-100px;時,如:圖2
當.container .box1設置margin-left:-100px;和margin-right:-100px時,如:圖3
<style>
body{
margin:0;
}
.container{
height: 500px;
width: 200px;
padding:0px 20px;
border:5px solid #ddd;
margin-top:100px;
}
.container .common{
width:200px;
height: 200px;
}
.container .box1{
background-color: skyblue;
/*margin-top:-100px;元素向上移動,下方元素也會受影響*/
margin-bottom:-100px;/*自身不受影響,下方元素向上移動*/
}
.container .box2{
background-color: rgba(0,0,255,0.5);
}
</style>
<body>
<div class="container">
<div class="box1 common"></div>
<div class="box2 common"></div>
</div>
</body>
當.container .box1中margin-top:-100px時,如:圖 1
當.container .box1中margin-bottom:-100px時,如:圖 2
當.container .box1中同時設置margin-top:-100px; 和margin-bottom:-100px;時,如:圖 3
這種布局的優點:
中間一欄內容最重要,最先加載和渲染,同時對搜索引擎優化最利。
兩邊內容固定,中間內容自適應
<style>
body{
margin:0;
/*核心代碼*/
min-width: 650px;/*當頁面寬度不夠時,出現滾動條而不會造成布局錯亂*/
}
.clearfix::after{
display: block;
content: "";
clear: both;
}
.fl{/*核心代碼*/
float:left;/*三個盒子一定要添加浮動*/
}
.header{
height: 100px;
background-color: tomato;
}
.container{
padding-left:200px;/*左邊預留200px位置 用來放left*/
padding-right:250px;/*右邊預留200px位置 用來放right*/
}
.container .center{
width:100%;/*自適應container的寬度,實現自適應縮放*/
height: 500px;
background-color: skyblue;
}
.container .left{
width:200px;
height: 500px;
background-color:cadetblue;
/*核心代碼*/
margin-left:-100%;/*盒子向左移,因為加了浮動,所以會移動到上一行的最左邊*/
position: relative;/*利用相對定位,再把盒子往左移200px就占據了最左邊預留的200px空間*/
left:-200px;
}
.container .right{
width:250px;
height: 500px;
background-color:aquamarine;
/*核心代碼*/
margin-right:-250px;/*加上這個代碼,相當于right沒有一點寬度,就會移動到上的最右邊位置*/
}
.footer{
height: 100px;
background-color: #000;
}
</style>
<body>
<div class="header">頭部</div>
<div class="container clearfix">
<div class="center fl">中間</div>
<div class="left fl">左邊</div>
<div class="right fl">右邊</div>
</div>
<div class="footer">底部</div>
</body>
這種布局的優點:
中間一欄內容最重要,最先加載和渲染,同時對搜索引擎優化最利。
兩邊內容固定,中間內容自適應
<style>
body{
margin:0;
}
.fl{/*核心代碼*/
float: left;/*一定要添加浮動*/
}
.main{
background-color: #ddd;
width:100%;
}
.main .main-content{
background-color: skyblue;
height: 300px;
/*核心代碼*/
margin:0 200px 0 200px;/*盒子左右兩邊余留200px,分別給left和right來占用*/
}
.left{
width: 200px;
height: 300px;
background-color: coral;
/*核心代碼*/
margin-left:-100%;/*往左移動瀏覽器的寬度,最后移動到上一行的最左邊*/
}
.right{
width: 200px;
height: 300px;
background-color: tomato;
/*核心代碼*/
margin-left:-200px;/*相當于自身寬度為0了,因為加了浮動,然后就顯示在了上一行的最右邊*/
}
</style>
<body>
<div class="main fl">
<div class="main-content">中間</div>
</div>
<div class="left fl">左邊</div>
<div class="right fl">右邊</div>
</body>
為幫助到一部分同學不走彎路,真正達到一線互聯網大廠前端項目研發要求,首次實力寵粉,打造了《30天挑戰學習計劃》,內容如下:
HTML/HTML5,CSS/CSS3,JavaScript,真實企業項目開發,云服務器部署上線,從入門到精通
共4大完整的項目開發 !一行一行代碼帶領實踐開發,實際企業開發怎么做我們就是怎么做。從學習一開始就進入工作狀態,省得浪費時間。
從學習一開始就同步使用 Git 進行項目代碼的版本的管理,Markdown 記錄學習筆記,包括真實大廠項目的開發標準和設計規范,命名規范,項目代碼規范,SEO優化規范
從藍湖UI設計稿 到 PC端,移動端,多端響應式開發項目開發
這些內容在《30天挑戰學習計劃》中每一個細節都有講到,包含視頻+圖文教程+項目資料素材等。只為實力寵粉,真正一次掌握企業項目開發必備技能,不走彎路 !
過程中【不涉及】任何費用和利益,非誠勿擾 。
如果你沒有添加助理老師微信,可以添加下方微信,說明要參加30天挑戰學習計劃,來自頭條號!老師會邀請你進入學習,并給你發放相關資料
30 天挑戰學習計劃 Web 前端從入門到實戰 | arry老師的博客-艾編程
讀:如果你的代碼是用 Python 編寫的,你應該使用 Textual 來幫助你編寫 TUI(文本用戶界面)。
快速入門使用 Textual
Python 在 Linux 上有像 TkInterdocs.python.org 這樣的優秀 GUI(圖形用戶界面)開發庫,但如果你不能運行圖形應用程序怎么辦?
文本終端,并非只在 Linux 上有,而且 BSD 和其它的出色的類 Unix 操作系統上也有。如果你的代碼是用 Python 編寫的,你應該使用 Textualtextual.textualize.io 來幫助你編寫 TUI(文本用戶界面)。在這個快速介紹中,我將向你展示兩個你可以用 Textual 做的示例,并且介紹它未來可能的應用方向。
所以 Textual 是什么?
Textual 是一個為 Python 構建的快速應用程序開發框架,由 Textualize.ioTextualize.io 構建。它可以讓你用簡單的 Python API 構建復雜的用戶界面,并運行在終端或網絡瀏覽器上!
你需要的跟進這個教程的工具
你需要有以下條件:
1. 具備基礎的編程經驗,最好熟練使用 Python。
2. 理解基礎的面向對象概念,比如類和繼承。
3. 一臺安裝了 Linux 與 Python 3.9+ 的機器
4. 一款好的編輯器(Vim 或者 PyCharm 是不錯的選擇)
我盡可能簡單化代碼,以便你能輕松理解。此外,我強烈建議你下載代碼,或至少按照接下來的說明安裝相關程序。
安裝步驟
首先創建一個虛擬環境:
python3 -m venv ~/virtualenv/Textualize
現在,你可以克隆 Git 倉庫并創建一個可以編輯的發布版本:
. ~/virtualenv/Textualize/bin/activate
pip install --upgrade pip
pip install --upgrade wheel
pip install --upgrade build
pip install --editable .
或者直接從 Pypi.orgPypi.org 安裝:
. ~/virtualenv/Textualize/bin/activate
pip install --upgrade KodegeekTextualize
我們的首個程序:日志瀏覽器
這個 日志瀏覽器 就是一款簡單的應用,能執行用戶 PATHmanpages.org 路徑上的一系列 UNIX 命令,并在任務執行完畢后捕獲輸出。
以下是該應用的代碼:
import shutil
from textual import on
from textual.app import ComposeResult, App
from textual.widgets import Footer, Header, Button, SelectionList
from textual.widgets.selection_list import Selection
from textual.screen import ModalScreen
# Operating system commands are hardcoded
OS_COMMANDS = {
"LSHW": ["lshw", "-json", "-sanitize", "-notime", "-quiet"],
"LSCPU": ["lscpu", "--all", "--extended", "--json"],
"LSMEM": ["lsmem", "--json", "--all", "--output-all"],
"NUMASTAT": ["numastat", "-z"]
}
class LogScreen(ModalScreen):
# ... Code of the full separate screen omitted, will be explained next
def __init__(self, name = None, ident = None, classes = None, selections = None):
super().__init__(name, ident, classes)
pass
class OsApp(App):
BINDINGS = [
("q", "quit_app", "Quit"),
]
CSS_PATH = "os_app.tcss"
ENABLE_COMMAND_PALETTE = False # Do not need the command palette
def action_quit_app(self):
self.exit(0)
def compose(self) -> ComposeResult:
# Create a list of commands, valid commands are assumed to be on the PATH variable.
selections = [Selection(name.title(), ' '.join(cmd), True) for name, cmd in OS_COMMANDS.items() if shutil.which(cmd[0].strip())]
yield Header(show_clock=False)
sel_list = SelectionList(*selections, id='cmds')
sel_list.tooltip = "Select one more more command to execute"
yield sel_list
yield Button(f"Execute {len(selections)} commands", id="exec", variant="primary")
yield Footer()
@on(SelectionList.SelectedChanged)
def on_selection(self, event: SelectionList.SelectedChanged) -> None:
button = self.query_one("#exec", Button)
selections = len(event.selection_list.selected)
if selections:
button.disabled = False
else:
button.disabled = True
button.label = f"Execute {selections} commands"
@on(Button.Pressed)
def on_button_click(self):
selection_list = self.query_one('#cmds', SelectionList)
selections = selection_list.selected
log_screen = LogScreen(selections=selections)
self.push_screen(log_screen)
def main():
app = OsApp()
app.title = f"Output of multiple well known UNIX commands".title()
app.sub_title = f"{len(OS_COMMANDS)} commands available"
app.run()
if __name__ == "__main__":
main()
現在我們逐條梳理一下程序的代碼:
1. 每個應用都擴展自 App 類。其中最重要的有 compose 與 mount 等方法。但在當前應用中,我們只實現了 composetextual.textualize.io。
2. 在 compose 方法中,你會返回一系列 組件textual.textualize.io(Widget),并按順序添加到主屏幕中。每一個組件都有定制自身外觀的選項。
3. 你可以設定單字母的 綁定textual.textualize.io(binding),比如此處我們設定了 q 鍵來退出應用(參見 action_quit_app 函數和 BINDINGS 列表)。
4. 利用 SelectionList 組件,我們展示了待運行的命令列表。然后,你可以通過 @on(SelectionList.SelectedChanged) 注解以及 on_selection 方法告知應用獲取所選的內容。
5. 對于無選定元素的應對很重要,我們會根據運行的命令數量來決定是否禁用 “exec” 按鈕。
6. 我們使用類似的監聽器( @on(Button.Pressed) )來執行命令。我們做的就是將我們的選擇送到一個新的屏幕,該屏幕會負責執行命令并收集結果。
你注意到 CSS_PATH = "os_app.tcss" 這個變量了嗎?Textual 允許你使用 CSS 來控制單個或多個組件的外觀(色彩、位置、尺寸):
Screen {
layout: vertical;
}
Header {
dock: top;
}
Footer {
dock: bottom;
}
SelectionList {
padding: 1;
border: solid $accent;
width: 1fr;
height: 80%;
}
Button {
width: 1fr
}
引自 Textual 官方網站:
Textual 中使用的 CSS 是互聯網上常見 CSS 的簡化版本,容易上手。
這真是太棒了,只需要用一個獨立的 樣式表textual.textualize.io,就可以輕松調整應用的樣式。
好,我們現在來看看如何在新屏幕上展示結果。
在新屏幕上展示結果
以下是在新屏幕上處理輸出的代碼:
import asyncio
from typing import List
from textual import on, work
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Log
from textual.worker import Worker
from textual.app import ComposeResult
class LogScreen(ModalScreen):
count = reactive(0)
MAX_LINES = 10_000
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "log_screen.tcss"
def __init__(
self,
name: str | None = None,
ident: str | None = None,
classes: str | None = None,
selections: List = None
):
super().__init__(name, ident, classes)
self.selections = selections
def compose(self) -> ComposeResult:
yield Label(f"Running {len(self.selections)} commands")
event_log = Log(
id='event_log',
max_lines=LogScreen.MAX_LINES,
highlight=True
)
event_log.loading = True
yield event_log
button = Button("Close", id="close", variant="success")
button.disabled = True
yield button
async def on_mount(self) -> None:
event_log = self.query_one('#event_log', Log)
event_log.loading = False
event_log.clear()
lst = '\n'.join(self.selections)
event_log.write(f"Preparing:\n{lst}")
event_log.write("\n")
for command in self.selections:
self.count += 1
self.run_process(cmd=command)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if self.count == 0:
button = self.query_one('#close', Button)
button.disabled = False
self.log(event)
@work(exclusive=False)
async def run_process(self, cmd: str) -> None:
event_log = self.query_one('#event_log', Log)
event_log.write_line(f"Running: {cmd}")
# Combine STDOUT and STDERR output
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
raise ValueError(f"'{cmd}' finished with errors ({proc.returncode})")
stdout = stdout.decode(encoding='utf-8', errors='replace')
if stdout:
event_log.write(f'\nOutput of "{cmd}":\n')
event_log.write(stdout)
self.count -= 1
@on(Button.Pressed, "#close")
def on_button_pressed(self, _) -> None:
self.app.pop_screen()
你會注意到:
1. LogScreen 類擴展自 ModalScreen 類, 該類負責處理模態模式的屏幕。
2. 這個屏幕同樣有一個 compose 方法,我們在這里添加了組件以展示 Unix 命令的內容。
3. 我們創建了一個叫做 mount 的新方法。一旦你用 compose 編排好組件,你就可以運行代碼來獲取數據,并再進一步定制它們的外觀。
4. 我們使用 asynciodocs.python.org 運行命令,這樣我們就能讓 TUI 主工作線程在每個命令的結果出來時就及時更新內容。
5. 對于“工作線程”,請注意 run_process 方法上的 @work(exclusive=False) 注解,該方法用于運行命令并捕獲 STDOUT + STDERR 輸出。使用 工作線程textual.textualize.io 來管理并發并不復雜,盡管它們在手冊中確實有專門的章節。這主要是因為運行的外部命令可能會執行很長時間。
6. 在 run_process 中,我們通過調用 write 以命令的輸出內容來更新 event_log。
7. 最后,on_button_pressed 把我們帶回到前一屏幕(從堆棧中移除屏幕)。
這個小應用向你展示了如何一份不到 200 行的代碼來編寫一個簡單的前端,用來運行非 Python 代碼。
現在我們來看一個更復雜的例子,這個例子用到了我們還未探索過的 Textual 的新特性。
示例二:展示賽事成績的表格
通過 Textual 創建的表格應用
本示例將展示如何使用 DataTable 組件在表格中展示賽事成績。你能通過這個應用實現:
? 通過列來排序表格
? 選擇表格中的行,完整窗口展示賽事細節,我們將使用我們在日志瀏覽器中看到的 “推送屏幕” 技巧。
? 能進行表格搜索,查看選手詳情,或執行其他操作如退出應用。
下面,我們來看看應用代碼:
#!/usr/bin/env python
"""
Author: Jose Vicente Nunez
"""
from typing import Any, List
from rich.style import Style
from textual import on
from textual.app import ComposeResult, App
from textual.command import Provider
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Header
MY_DATA = [
("level", "name", "gender", "country", "age"),
("Green", "Wai", "M", "MYS", 22),
("Red", "Ryoji", "M", "JPN", 30),
("Purple", "Fabio", "M", "ITA", 99),
("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "details_screen.tcss"
def __init__(
self,
name: str | None = None,
ident: str | None = None,
classes: str | None = None,
row: List[Any] | None = None,
):
super().__init__(name, ident, classes)
# Rest of screen code will be show later
class CustomCommand(Provider):
def __init__(self, screen: Screen[Any], match_style: Style | None = None):
super().__init__(screen, match_style)
self.table = None
# Rest of provider code will be show later
class CompetitorsApp(App):
BINDINGS = [
("q", "quit_app", "Quit"),
]
CSS_PATH = "competitors_app.tcss"
# Enable the command palette, to add our custom filter commands
ENABLE_COMMAND_PALETTE = True
# Add the default commands and the TablePopulateProvider to get a row directly by name
COMMANDS = App.COMMANDS | {CustomCommand}
def action_quit_app(self):
self.exit(0)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
table = DataTable(id=f'competitors_table')
table.cursor_type = 'row'
table.zebra_stripes = True
table.loading = True
yield table
yield Footer()
def on_mount(self) -> None:
table = self.get_widget_by_id(f'competitors_table', expect_type=DataTable)
columns = [x.title() for x in MY_DATA[0]]
table.add_columns(*columns)
table.add_rows(MY_DATA[1:])
table.loading = False
table.tooltip = "Select a row to get more details"
@on(DataTable.HeaderSelected)
def on_header_clicked(self, event: DataTable.HeaderSelected):
table = event.data_table
table.sort(event.column_key)
@on(DataTable.RowSelected)
def on_row_clicked(self, event: DataTable.RowSelected) -> None:
table = event.data_table
row = table.get_row(event.row_key)
runner_detail = DetailScreen(row=row)
self.show_detail(runner_detail)
def show_detail(self, detailScreen: DetailScreen):
self.push_screen(detailScreen)
def main():
app = CompetitorsApp()
app.title = f"Summary".title()
app.sub_title = f"{len(MY_DATA)} users"
app.run()
if __name__ == "__main__":
main()
有哪些部分值得我們關注呢?
1. compose 方法中添加了 表頭textual.textualize.io,“命令面板” 就位于此處,我們的表格(DataTabletextual.textualize.io)也在這里。表格數據在 mount 方法中填充。
2. 我們設定了預期的綁定(BINDINGS),并指定了外部的 CSS 文件來設置樣式(CSS_PATH)。
3. 默認情況下,我們無需任何設置便能使用 命令面板textual.textualize.io,但在此我們顯式啟用了它(ENABLE_COMMAND_PALETTE = True)。
4. 我們的應用有一個自定義表格搜索功能。當用戶輸入一名選手的名字后,應用會顯示可能的匹配項,用戶可以點擊匹配項查看該選手的詳細信息。這需要告訴應用我們有一個定制的命令提供者(COMMANDS = App.COMMANDS | {CustomCo_ mmand}),即類 CustomCommand(Provider)。
5. 如果用戶點擊了表頭,表格內容會按照該列進行排序。這是通過 on_header_clicked 方法實現的,該方法上具有 @on(DataTable.HeaderSelected) 注解。
6. 類似地,當選中表格中的一行時, on_row_clicked 方法會被調用,這得益于它擁有 @on(DataTable.RowSelected) 注解。當方法接受選中的行后,它會推送一個新的屏幕,顯示選中行的詳細信息(class DetailScreen(ModalScreen))。
現在,我們詳細地探討一下如何顯示選手的詳細信息。
利用多屏展示復雜視圖
當用戶選擇表格中的一行,on_row_clicked 方法就會被調用。它收到的是一個 DataTable.RowSelected 類型的事件。從這里我們會用選中的行的內容構建一個 DetailScreen(ModalScreen) 類的實例:
from typing import Any, List
from textual import on
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Button, MarkdownViewer
MY_DATA = [
("level", "name", "gender", "country", "age"),
("Green", "Wai", "M", "MYS", 22),
("Red", "Ryoji", "M", "JPN", 30),
("Purple", "Fabio", "M", "ITA", 99),
("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "details_screen.tcss"
def __init__(
self,
name: str | None = None,
ident: str | None = None,
classes: str | None = None,
row: List[Any] | None = None,
):
super().__init__(name, ident, classes)
self.row: List[Any] = row
def compose(self) -> ComposeResult:
self.log.info(f"Details: {self.row}")
columns = MY_DATA[0]
row_markdown = "\n"
for i in range(0, len(columns)):
row_markdown += f"* **{columns[i].title()}:** {self.row[i]}\n"
yield MarkdownViewer(f"""## User details:
{row_markdown}
""")
button = Button("Close", variant="primary", id="close")
button.tooltip = "Go back to main screen"
yield button
@on(Button.Pressed, "#close")
def on_button_pressed(self, _) -> None:
self.app.pop_screen()
這個類的職責很直接:
1. compose 方法取得此行數據,并利用一個 支持 Markdown 渲染的組件textual.textualize.io 來展示內容。它的便利之處在于,它會為我們自動生成一個內容目錄。
2. 當用戶點擊 “close” 后,方法 on_button_pressed 會引導應用回到原始屏幕。注解 @on(Button.Pressed, "#close") 用來接收按鍵被點擊的事件。
最后,我們來詳細講解一下那個多功能的搜索欄(也叫做命令面板)。
命令面板的搜索功能
任何使用了表頭的 Textual 應用都默認開啟了 命令面板textual.textualize.io。有意思的是,你可以在 CompetitorsApp 類中添加自定義的命令,這會增加到默認命令集之上:
COMMANDS = App.COMMANDS | {CustomCommand}
然后是執行大部分任務的 CustomCommand(Provider) 類:
from functools import partial
from typing import Any, List
from rich.style import Style
from textual.command import Provider, Hit
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable
from textual.app import App
class CustomCommand(Provider):
def __init__(self, screen: Screen[Any], match_style: Style | None = None):
super().__init__(screen, match_style)
self.table = None
async def startup(self) -> None:
my_app = self.app
my_app.log.info(f"Loaded provider: CustomCommand")
self.table = my_app.query(DataTable).first()
async def search(self, query: str) -> Hit:
matcher = self.matcher(query)
my_app = self.screen.app
assert isinstance(my_app, CompetitorsApp)
my_app.log.info(f"Got query: {query}")
for row_key in self.table.rows:
row = self.table.get_row(row_key)
my_app.log.info(f"Searching {row}")
searchable = row[1]
score = matcher.match(searchable)
if score > 0:
runner_detail = DetailScreen(row=row)
yield Hit(
score,
matcher.highlight(f"{searchable}"),
partial(my_app.show_detail, runner_detail),
help=f"Show details about {searchable}"
)
class DetailScreen(ModalScreen):
def __init__(
self,
name: str | None = None,
ident: str | None = None,
classes: str | None = None,
row: List[Any] | None = None,
):
super().__init__(name, ident, classes)
# Code of this class explained on the previous section
class CompetitorsApp(App):
# Add the default commands and the TablePopulateProvider to get a row directly by name
COMMANDS = App.COMMANDS | {CustomCommand}
# Most of the code shown before, only displaying relevant code
def show_detail(self, detailScreen: DetailScreen):
self.push_screen(detailScreen)
1. 所有繼承自 Provider 的類需實現 search 方法。在我們的例子中,我們還覆蓋了 startup 方法,為了獲取到我們應用表格(和其內容)的引用,這里使用到了 App.query(DataTable).first()。在類的生命周期中, startup 方法只會被調用一次。
2. 在 search 方法內,我們使用 Provider.matcher 對每個表格行的第二列(即名字)進行模糊搜索,以與用戶在 TUI 中輸入的詞條進行比較。matcher.match(searchable) 返回一個整型的評分,大于零說明匹配成功。
3. 在 search 方法中,如果評分大于零,則返回一個 Hit 對象,以告知命令面板搜索查詢是否成功。
4. 每個 Hit 都有以下信息:評分(用于在命令面板中對匹配項排序)、高亮顯示的搜索詞、一個可調用對象的引用(在我們的案例中,它是一個可以將表格行推送到新屏幕的函數)。
5. Provider 類的所有方法都是異步的。這使你能釋放主線程,只有當響應準備好后才返回結果,這個過程不會凍結用戶界面。
理解了這些信息,我們就可以現在展示賽手的詳細信息了。
盡管這個架構的追蹤功能相對直觀,但是組件間傳遞的消息復雜性不可忽視。幸運的是,Textual 提供了有效的調試工具幫助我們理解背后的工作原理。
Textual 應用的問題排查
對于 Python 的 Textual 應用進行 調試github.com 相較而言更具挑戰性。這是因為其中有一些操作可能是異步的,而在解決組件問題時設置斷點可能頗為復雜。
根據具體情況,你可以使用一些工具。但首先,確保你已經安裝了 textual 的開發工具:
pip install textual-dev==1.3.0
確保你能捕捉到正確的按鍵
不確定 Textual 應用是否能捕捉到你的按鍵操作?運行 keys 應用:
textual keys
這讓你能夠驗證一下你的按鍵組合,并確認在 Textual 中產生了哪些事件。
圖片比千言萬語更直觀
如果說你在布局設計上遇到了問題,想向他人展示你當前的困境,Textual 為你的運行應用提供了截圖功能:
textual run --screenshot 5 ./kodegeek_textualize/log_scroller.py
就像你所看到的,我是通過這種方式為這篇教程創建了插圖。
捕獲事件并輸出定制消息
在 Textual 中,每一個應用實例都有一個日志記錄器,可以使用如下方式訪問:
my_app = self.screen.app
my_app.log.info(f"Loaded provider: CustomCommand")
想要查看這些消息,首先需要開啟一個控制臺:
. ~/virtualenv/Textualize/bin/activate
textual console
然后在另一個終端運行你的應用程序:
. ~/virtualenv/Textualize/bin/activate
textual run --dev ./kodegeek_textualize/log_scroller.py
在運行控制臺的終端中,你可以看到實時的事件和消息輸出:
▌Textual Development Console v0.46.0
▌Run a Textual app with textual run --dev my_app.py to connect.
▌Press Ctrl+C to quit.
─────────────────────────────────────────────────────────────────────────────── Client '127.0.0.1' connected ────────────────────────────────────────────────────────────────────────────────
[20:29:43] SYSTEM app.py:2188
Connected to devtools ( ws://127.0.0.1:8081 )
[20:29:43] SYSTEM app.py:2192
---
[20:29:43] SYSTEM app.py:2194
driver=<class 'textual.drivers.linux_driver.LinuxDriver'>
[20:29:43] SYSTEM app.py:2195
loop=<_UnixSelectorEventLoop running=True closed=False debug=False>
[20:29:43] SYSTEM app.py:2196
features=frozenset({'debug', 'devtools'})
[20:29:43] SYSTEM app.py:2228
STARTED FileMonitor({PosixPath('/home/josevnz/TextualizeTutorial/docs/Textualize/kodegeek_textualize/os_app.tcss')})
[20:29:43] EVENT
此外,以開發者模式運行的另一大好處是,如果你更改了 CSS,應用會嘗試重新渲染,而無需重啟程序。
如何編寫單元測試
為你全新開發的 Textual 應用編寫 單元測試docs.python.org,應該如何操作呢?
在 官方文檔textual.textualize.io 展示了幾種用于測試我們應用的方式。
我將采用 unittestdocs.python.org 進行測試。為了處理異步例程,我們會需要特別的類 unittest.IsolatedAsyncioTestCase :
import unittest
from textual.widgets import Log, Button
from kodegeek_textualize.log_scroller import OsApp
class LogScrollerTestCase(unittest.IsolatedAsyncioTestCase):
async def test_log_scroller(self):
app = OsApp()
self.assertIsNotNone(app)
async with app.run_test() as pilot:
# Execute the default commands
await pilot.click(Button)
await pilot.pause()
event_log = app.screen.query(Log).first() # We pushed the screen, query nodes from there
self.assertTrue(event_log.lines)
await pilot.click("#close") # Close the new screen, pop the original one
await pilot.press("q") # Quit the app by pressing q
if __name__ == '__main__':
unittest.main()
現在讓我們詳細看看 test_log_scroller 方法中的操作步驟:
1. 通過 app.run_test() 獲取一個 Pilot 實例。然后點擊主按鈕,運行包含默認指令的查詢,隨后等待所有事件的處理。
2. 從我們新推送出的屏幕中獲取 Log,確保我們已獲得幾行返回的內容,即它并非空的。
3. 關閉新屏幕并重新呈現舊屏幕。
4. 最后,按下 q,退出應用。
可以測試表格嗎?
import unittest
from textual.widgets import DataTable, MarkdownViewer
from kodegeek_textualize.table_with_detail_screen import CompetitorsApp
class TableWithDetailTestCase(unittest.IsolatedAsyncioTestCase):
async def test_app(self):
app = CompetitorsApp()
self.assertIsNotNone(app)
async with app.run_test() as pilot:
"""
Test the command palette
"""
await pilot.press("ctrl+\\")
for char in "manuela".split():
await pilot.press(char)
await pilot.press("enter")
markdown_viewer = app.screen.query(MarkdownViewer).first()
self.assertTrue(markdown_viewer.document)
await pilot.click("#close") # Close the new screen, pop the original one
"""
Test the table
"""
table = app.screen.query(DataTable).first()
coordinate = table.cursor_coordinate
self.assertTrue(table.is_valid_coordinate(coordinate))
await pilot.press("enter")
await pilot.pause()
markdown_viewer = app.screen.query(MarkdownViewer).first()
self.assertTrue(markdown_viewer)
# Quit the app by pressing q
await pilot.press("q")
if __name__ == '__main__':
unittest.main()
如果你運行所有的測試,你將看到如下類似的輸出:
(Textualize) [josevnz@dmaf5 Textualize]$ python -m unittest tests/*.py
..
----------------------------------------------------------------------
Ran 2 tests in 2.065s
OK
這是測試 TUI 的一個不錯的方式,對吧?
打包 Textual 應用
打包 Textual 應用與打包常規 Python 應用并沒有太大區別。你需要記住,需要包含那些控制應用外觀的 CSS 文件:
. ~/virtualenv/Textualize/bin/activate
python -m build
pip install dist/KodegeekTextualize-*-py3-none-any.whl
這個教程的 pyproject.tomltutorials.kodegeek.com 文件是一個打包應用的良好起點,告訴你需要做什么。
[build-system]
requires = [
"setuptools >= 67.8.0",
"wheel>=0.42.0",
"build>=1.0.3",
"twine>=4.0.2",
"textual-dev>=1.2.1"
]
build-backend = "setuptools.build_meta"
[project]
name = "KodegeekTextualize"
version = "0.0.3"
authors = [
{name = "Jose Vicente Nunez", email = "kodegeek.com@protonmail.com"},
]
description = "Collection of scripts that show how to use several features of textualize"
readme = "README.md"
requires-python = ">=3.9"
keywords = ["running", "race"]
classifiers = [
"Environment :: Console",
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Intended Audience :: End Users/Desktop",
"Topic :: Utilities"
]
dynamic = ["dependencies"]
[project.scripts]
log_scroller = "kodegeek_textualize.log_scroller:main"
table_detail = "kodegeek_textualize.table_with_detail_screen:main"
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
exclude = ["test*"]
[tool.setuptools.package-data]
kodegeek_textualize = ["*.txt", "*.tcss", "*.csv"]
img = ["*.svg"]
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}
未來計劃
這個簡短的教程只覆蓋了 Textual 的部分方面。還有很多需要探索和學習的內容:
? 強烈建議你查看 官方教程textual.textualize.io。有大量的示例和指向參考 APItextual.textualize.io 的鏈接。
? Textual 可以使用來自 Richgithub.com 項目的組件,這個項目是一切的起源。我認為其中一些甚至可能所有這些組件在某些時候都會合并到 Textual 中。Textual 框架對于使用高級 API 的復雜應用更能勝任,但 Rich 也有很多漂亮的功能。
? 創建你自己的組件!同樣,在設計 TUI 時,拿一張紙,畫出你希望這些組件如何布局的textual.textualize.io,這會為你后期省去很多時間和麻煩。
? 調試 Python 應用可能會有點復雜。有時你可能需要 混合使用不同的工具github.com 來找出應用的問題所在。
? 異步 IO 是一個復雜的話題,你應該 閱讀開發者文檔docs.python.org 來了解更多可能的選擇。
? Textual 被其他項目所使用。其中一個非常易于使用的項目是 Trogongithub.com。它會讓你的 CLI 可以自我發現github.com。
? Textual-webgithub.com 是個很有前景的項目,能讓你在瀏覽器上運行 Textual 應用。盡管它不如 Textual 成熟,但它的進化速度非常快。
? 最后,查看這些外部項目www.textualize.io。在項目組合中有許多有用的開源應用。
via: https://fedoramagazine.org/crash-course-on-using-textual/
blog-engine-01-常見博客引擎 jekyll/hugo/Hexo/Pelican/Gatsby/VuePress/Nuxt.js/Middleman 對比[1]
blog-engine-02-通過博客引擎 jekyll 構建 github pages 博客實戰筆記[2]
blog-engine-02-博客引擎jekyll-jekyll 博客引擎介紹[3]
blog-engine-02-博客引擎jekyll-jekyll 如何在 windows 環境安裝,官方文檔[4]
blog-engine-02-博客引擎jekyll-jekyll SEO[5]
blog-engine-04-博客引擎 hugo intro 入門介紹+安裝筆記[6]
blog-engine-05-博客引擎 Hexo 入門介紹+安裝筆記[7]
blog-engine-06-pelican 靜態網站生成 官方文檔[8]
blog-engine-06-pelican 靜態網站生成 windows 安裝實戰[9]
blog-engine-07-gatsby 建極速網站和應用程序 基于React的最佳框架,具備性能、可擴展性和安全性[10]
blog-engine-08-vuepress 以 Markdown 為中心的靜態網站生成器[11]
blog-engine-09-nuxt 構建快速、SEO友好和可擴展的Web應用程序變得輕松[12]
blog-engine-10-middleman 靜態站點生成器,利用了現代 Web 開發中的所有快捷方式和工具[13]
由于個人一直喜歡使用 markdown 來寫 個人博客[14],最近就整理了一下有哪些博客引擎。
感興趣的小伙伴也可以選擇自己合適的。
Pelican[15] 是一個靜態網站生成器,用Python編寫,它允許您通過編寫Markdown、reStructuredText和HTML等格式的文本文件來創建網站。
使用Pelican,您可以創建網站而無需擔心數據庫或服務器端編程。Pelican生成可以通過任何網絡服務器或托管服務提供的靜態站點。
您可以使用Pelican執行以下功能:
?
使用您選擇的編輯器在Markdown或reStructuredText中編寫內容
?
簡單的命令行工具重新生成HTML、CSS和JS源內容
?
易于與版本控制系統和Web掛鉤進行接口
?
完全靜態的輸出可以簡單地托管在任何地方
Pelican的功能亮點包括:
?
時間排序的內容(例如,文章、博客文章)以及靜態頁面
?
與外部服務的集成
?
站點主題(使用Jinja2模板創建)
?
在多種語言中發布文章
?
生成Atom和RSS訂閱源
?
通過Pygments進行代碼語法高亮顯示
?
從WordPress、Dotclear或RSS訂閱源導入現有內容
?
由于內容緩存和選擇性輸出編寫,重建速度快
?
可通過豐富的插件生態系統進行擴展:Pelican插件
查看 Pelican 文檔[16] 以獲取更多信息。
“Pelican”是“calepin”的一個變位詞,法語中意為“筆記本”。
該存儲庫包含了用于Pelican的主題。請隨意克隆、添加您自己的主題,并提交拉取請求。這是由社區管理的!
您可以在 http://www.pelicanthemes.com 查看實時版本。
以下說明假定您已經閱讀了所有Pelican文檔,有一個工作站點,并且現在想要應用一個非默認主題。
首先,選擇一個位置來存放您的主題。對于這個示例,我們將使用目錄~/pelican-themes,但您的目錄可能不同。
在您的本地機器上將pelican-themes存儲庫克隆到該位置:
git clone --recursive https://github.com/getpelican/pelican-themes ~/pelican-themes
現在您應該在 ~/pelican-themes/ 下存儲您的 pelican-themes 存儲庫。
要使用其中一個主題,請編輯您的Pelican設置文件以包含以下行:
THEME = "/home/user/pelican-themes/theme-name"
所以,例如,要使用mnmlist主題,您將編輯您的設置文件以包含:
THEME = "/home/user/pelican-themes/mnmlist"
保存對設置文件的更改,然后使用您已經設置的 pelican-quickstart Makefile 重新生成您的站點:
make html
也可以通過-pelican命令的 -t ~/pelican-themes/theme-name 參數直接指定主題。
如果您想要編輯您的主題,請確保您所做的任何編輯都是針對存儲在~/pelican-themes/theme-name中的副本進行的。
對于存儲在站點輸出目錄中的文件所做的任何更改都將在下次生成站點時被刪除。
重要提示:我們正在將插件從這個單一的存儲庫遷移到它們自己的獨立存儲庫,這些存儲庫位于新的Pelican插件組織下,這是一個供插件作者與Pelican維護者和社區其他成員更廣泛合作的地方。我們的意圖是讓所有新組織下的插件都采用新的“命名空間插件”格式,這意味著這些插件可以輕松地通過Pip安裝,并且Pelican 4.5+可以立即識別它們——而不必顯式啟用它們。
這個過渡過程需要一些時間,因此我們感謝您在此期間的耐心等待。如果您想幫助加速這個過渡,以下內容將非常有幫助:
?如果您在這里找到一個尚未遷移到新組織的插件,請在這個存儲庫下創建一個新的問題,并說明您想要幫助遷移的插件,之后Pelican維護者將指導您完成此過程。?如果您來到這里提交一個拉取請求以添加您的插件,請考慮將您的插件移動到Pelican插件組織下。要開始,請在這個存儲庫下創建一個新的問題,提供您插件的詳細信息,之后Pelican維護者將指導您完成此過程。?無論您是創建新插件還是遷移現有插件,請使用提供的Cookiecutter模板生成符合社區約定的腳手架命名空間插件。查看Simple Footnotes存儲庫,以查看一個已遷移插件的示例。
以下其余信息與傳統插件相關,但不適用于Pelican插件組織中的新命名空間插件。
安裝和使用這些插件的最簡單方法是克隆這個存儲庫:
git clone --recursive https://github.com/getpelican/pelican-plugins
并在您的設置文件中激活您想要的插件:
PLUGIN_PATHS = ['path/to/pelican-plugins']
PLUGINS = ['assets', 'sitemap', 'gravatar']
PLUGIN_PATHS可以是相對于您的設置文件的路徑,也可以是絕對路徑。
或者,如果插件位于可導入的路徑中,您可以省略PLUGIN_PATHS并列出它們:
PLUGINS = ['assets', 'sitemap', 'gravatar']
或者您可以直接導入插件并給出:
import my_plugin
PLUGINS = [my_plugin, 'assets']
遷移狀態:
(blank):本地托管插件仍在等待遷移工作。
??:已棄用。可以安全地從此存儲庫中刪除。
?:由外部維護的插件,不需要從單一存儲庫顯式遷移。遷移工作需要在原始所有者的存儲庫中進行。
??:存儲庫已遷移到Pelican插件組織。
插件 | 狀態 | 描述 |
Ace Editor | ? | 將默認的替換為在pelicanconf.py上配置的Ace代碼編輯器。 |
Always modified | 將創建日期元數據復制到修改日期,以便在“最新更新”索引中輕松查找。 | |
AsciiDoc reader | 使用AsciiDoc編寫您的帖子。 | |
Asset management | ? | 使用Webassets模塊管理資產,如CSS和JS文件。 |
Author images | 添加對作者圖片和頭像的支持。 | |
Auto Pages | 為生成的作者、分類和標簽頁面生成自定義內容(例如作者傳記)。 | |
Backref Translate | ? | 為每篇文章/頁面(作為翻譯的一部分)添加一個新屬性(is_translation_of),指向原始文章/頁面。 |
Better code samples | ? | 使用div > .hilitewrapper > .codehilitetable類屬性包裝表格塊,允許滾動代碼塊。 |
Better code line numbers | 允許帶有行號的代碼塊換行。 | |
Better figures/samples | 為內容中的任何標簽添加style="width: ???px; height: auto;"屬性。 | |
Better tables | 刪除reST生成的HTML表中的多余屬性和元素。 | |
bootstrap-rst | 提供大多數(盡管不是全部)Bootstrap的rst指令。 | |
bootstrapify | ? | 自動將bootstrap的默認類添加到您的內容中。 |
Category meta | 從該類別目錄中的索引文件讀取每個類別的元數據。 | |
Category Order | ? | 按照該類別(或標簽)中的文章數量對類別(或標簽)進行排序。 |
CJK auto spacing | ? | 在中文/日文/韓文字符和英文單詞之間插入空格。 |
Clean summary | 清除摘要中多余的圖像。 | |
Code include | 在reStructuredText中包含Pygments突出顯示的代碼。 | |
Collate content | 將內容的類別作為列表通過collations屬性提供給模板。 | |
Creole reader | 使用wikicreole語法編寫您的帖子。 | |
CSS HTML JS Minify | 在站點生成后,對所有CSS、HTML和JavaScript文件進行最小化。 | |
CTags generator | 生成一個“tags”文件,按照“content/”目錄中的CTags,以提供對支持它的代碼編輯器的自動完成。 | |
Custom article URLs | 支持為不同的類別定義不同的默認URL。 | |
Dateish | 將任意元數據字段視為datetime對象。 | |
Dead Links | ? | 管理失效的鏈接(網站不可用,錯誤如403、404)。 |
Disqus static comments | 向所有文章添加disqus_comments屬性。評論在生成時使用disqus API獲取。 | |
Encrypt content | ? | 為頁面和文章設置密碼保護。 |
Events | 將事件開始、持續時間和位置信息添加到帖子元數據中,以生成iCalendar文件。 | |
Extract table of content | 從文章內容中提取目錄(ToC)。 | |
Feed summary | ?? | 允許將文章摘要用于ATOM和RSS訂閱源,而不是整篇文章。 |
Figure References | ? | 提供一個系統來編號和引用圖像。 |
Filetime from Git | 使用Git提交確定頁面日期。 | |
Filetime from Hg | 使用Mercurial提交確定頁面日期。 | |
Footer Insert | 在每篇文章的末尾添加標準化的頁腳(例如作者信息)。 | |
GA Page View | ? | 在個別文章和頁面上顯示Google Analytics頁面視圖。 |
Gallery | 允許一篇文章包含一個相冊。 | |
Gist directive | 此插件添加了一個gist reStructuredText指令。 | |
GitHub wiki | 將平面的github wiki轉換為結構化的只讀wiki,放在您的站點上。 | |
GitHub activity | 在模板方面,您只需迭代github_activity變量。 | |
Global license | 允許您定義一個LICENSE設置,并將該許可變量的內容添加到文章的上下文中。 | |
Glossary | 添加包含從文章和頁面中的定義列表中提取的定義的變量。此變量對所有頁面模板可見。 | |
Goodreads activity | 列出您的Goodreads書架上的書籍。 | |
GooglePlus comments | 向Pelican添加GooglePlus評論。 | |
Gravatar | ? | 此插件的功能已由更新的Avatar插件取代。 |
Gzip cache | 啟用某些網絡服務器(例如Nginx)使用gzip壓縮文件的靜態緩存,以防止在HTTP調用期間服務器對文件進行壓縮。 | |
Headerid | 此插件為每個標題添加一個錨點,以便您可以在reStructuredText文章中進行深度鏈接。 | |
HTML entities | 允許您在RST文檔中內聯輸入HTML實體,如?、<、?。 | |
HTML tags for rST | 允許您在reST文檔中使用HTML標簽。 | |
I18N Sub-sites | 通過為默認站點創建國際化子站點來擴展翻 |
[1] blog-engine-01-常見博客引擎 jekyll/hugo/Hexo/Pelican/Gatsby/VuePress/Nuxt.js/Middleman 對比: https://houbb.github.io/2016/04/13/blog-engine-01-overview
[2] blog-engine-02-通過博客引擎 jekyll 構建 github pages 博客實戰筆記: https://houbb.github.io/2016/04/13/blog-engine-02-jekyll-01-install
[3] blog-engine-02-博客引擎jekyll-jekyll 博客引擎介紹: https://houbb.github.io/2016/04/13/blog-engine-03-jekyll-02-intro
[4] blog-engine-02-博客引擎jekyll-jekyll 如何在 windows 環境安裝,官方文檔: https://houbb.github.io/2016/04/13/blog-engine-03-jekyll-03-install-on-windows-doc
[5] blog-engine-02-博客引擎jekyll-jekyll SEO: https://houbb.github.io/2016/04/13/blog-engine-03-jekyll-04-seo
[6] blog-engine-04-博客引擎 hugo intro 入門介紹+安裝筆記: https://houbb.github.io/2016/04/13/blog-engine-04-hugo-intro
[7] blog-engine-05-博客引擎 Hexo 入門介紹+安裝筆記: https://houbb.github.io/2017/03/29/blog-engine-05-hexo
[8] blog-engine-06-pelican 靜態網站生成 官方文檔: https://houbb.github.io/2016/04/13/blog-engine-06-pelican-01-intro
[9] blog-engine-06-pelican 靜態網站生成 windows 安裝實戰: https://houbb.github.io/2016/04/13/blog-engine-06-pelican-02-quick-start
[10] blog-engine-07-gatsby 建極速網站和應用程序 基于React的最佳框架,具備性能、可擴展性和安全性: https://houbb.github.io/2016/04/13/blog-engine-07-gatsby-01-intro
[11] blog-engine-08-vuepress 以 Markdown 為中心的靜態網站生成器: https://houbb.github.io/2016/04/13/blog-engine-08-vuepress-01-intro
[12] blog-engine-09-nuxt 構建快速、SEO友好和可擴展的Web應用程序變得輕松: https://houbb.github.io/2016/04/13/blog-engine-09-nuxt-01-intro
[13] blog-engine-10-middleman 靜態站點生成器,利用了現代 Web 開發中的所有快捷方式和工具: https://houbb.github.io/2016/04/13/blog-engine-10-middleman-01-intro
[14] 個人博客: https://houbb.github.io/
[15] Pelican: https://github.com/getpelican/pelican
[16] Pelican 文檔: https://docs.getpelican.com/en/latest/
*請認真填寫需求信息,我們會在24小時內與您取得聯系。