Petal 是一个极简、唯美、无需任何构建工具链的纯前端日记/碎碎念框架。没有冗余的依赖,只需最纯粹的 HTML、TXT 和 TOML,即可在任何支持静态托管的平台上部署。
- 文件驱动: 所有数据均为人类可读的 TOML, TXT
- 无服务器: 通过组合 Cloudflare Workers 与 R2, 将数据源设置为远程 URL, 即可实现非常丝滑的书写体验
- 自由随写: 没有固定的时间戳格式与排序, 格式由你定义
- 隐私保障: 敏感内容分发时采用端到端 AES-GCM 加密, 应用内解密
- 丝滑书写: 配置仓库鉴权文件即可在网页启动内置的编辑器
- 私密日记: 可配置秘密时间线, 外观上拥有更加沉浸的氛围
- 特殊语法: 支持 Markdown 超链接与图片语法, 还支持完备的自定义语法规则
- 标签系统: 自动提取正文首行的
#标签并渲染过滤导航组件 - 体验优先: 为人类优化交互体验, 例如未读系统, 鉴权记忆等
无论是哪种部署方式,第一步都是在本地初始化你的个人配置和数据源
在一个空文件夹内,运行以下命令创建 Petal Note 数据模板
curl -fsSL https://raw.githubusercontent.com/miniyu157/petal-note/main/scripts/create-petal-app.sh | bash -e可选自动配置 .gitignore 忽略 *.html 或其它部署文件
将整个文件夹托管到任意静态服务平台, 如 Cloudflare Pages, Vercel, GitHub Pages, 保留稳定的 HTML 骨架
如果你希望外观和特性永远保持最新, 并与个人日记解耦
-
在静态服务平台托管你的仓库, 无需放入 HTML 和任何加密文件
-
在部署设置中, 将 Build command 设置为:
curl -fsSL https://raw.githubusercontent.com/miniyu157/petal-note/e310ca1/scripts/build.sh | bash -e -s -- index.html syntax.toml -
将构建输出目录 Output Directory 设置为
public
Note
如果希望手动配置语法文件, 而不是跟随仓库更新, 则去掉 Build command 末尾的 syntax.toml
这样, 你的仓库触发 Deploy 时, 都会自动拉取并注入最新版本的 Petal Note 骨架, 同时自动处理 各种文件的加密 与 分发 , 个人内容仓库保持纯净
若想要仅更新骨架, 手动运行一次
Redeploy
Petal Note 内置了一个编辑器, 通过 GitHub Git Data REST API(Blobs / Trees / Commits / Refs)实现仓库文件管理能力
正确设置 editor_config 后, 将在页面左下角显示一个淡淡的编辑器入口按钮
editor_config 的值应为 AES-GCM 加密的 TOML 文件, 包含目标仓库信息, 帐号令牌等, 目前只支持连接 GitHub 仓库
明文格式如下, 将在应用内解锁, 解锁成功后打开编辑器
github_user = "user"
github_repo = "repo"
github_token = "github_pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
commit_msg = "web_editor: {{yyyy-mm-dd hh:mm:ss}}"
commit_user = "Web Editor"
commit_email = "example@mail.com"
[image]
api_url = "https://img-upload.example.com"
api_key = "xxxxxxxxxxxxxxxxx"
img_domain = "https://img.example.com"
default_dir = "images"其中 data_path 和 private_path 是数据文件位于远程仓库的路径
编辑器允许直接上传图片, 需要配置一个接受 PUT 请求的图片上传 API, 并在 [image] 块中完成对应设置
例如使用 Cloudflare R2 配合 Workers 搭建, 只需要创建一个 R2 存储桶和一个 Worker, 并在 Worker 中绑定你的存储桶, 需要设置以下两个变量
-
MY_BUCKET: 位于绑定选项卡, 填入存储桶名称
-
UPLOAD_TOKEN: 位于环境变量, 可以使用以下命令生成
openssl rand -hex 32将结果填入, 对应配置文件中的
api_key
完成变量关联后, 将以下代码粘贴到 Workers 中并部署:
点击展开代码
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
};
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'PUT') {
return new Response('Method Not Allowed', { status: 405, headers: corsHeaders });
}
const authHeader = request.headers.get('Authorization');
const expectedAuth = `Bearer ${env.UPLOAD_TOKEN}`;
if (!authHeader || authHeader !== expectedAuth) {
return new Response('Unauthorized: Invalid or missing token', { status: 401, headers: corsHeaders });
}
const url = new URL(request.url);
const objectName = decodeURIComponent(url.pathname.slice(1));
if (!objectName) {
return new Response('Bad Request: Missing file path in URL', { status: 400, headers: corsHeaders });
}
try {
await env.MY_BUCKET.put(objectName, request.body, {
httpMetadata: {
contentType: request.headers.get('Content-Type') || 'application/octet-stream',
}
});
const spaceEncodedPath = objectName.replace(/ /g, '%20');
return new Response(JSON.stringify({
success: true,
message: 'File uploaded successfully',
path: spaceEncodedPath
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
...corsHeaders
}
});
} catch (error) {
return new Response(`Internal Server Error: ${error.message}`, { status: 500, headers: corsHeaders });
}
}
};部署成功后, 将 Worker 的路由地址填入 api_url, 如果存储桶绑定了自定义域名, 将其填入 img_domain
通过配置 config.toml 指向的 syntax_config 文件即可定义语法
以下是一个简单的示例:
[[rules]]
# 定义基本语法
prefix = "**"
suffix = "**"
# 填入元素
replacement = '<strong class="text-bold">$1</strong>'
# 具体渲染样式
css = ".text-bold { font-weight: 600; color: var(--text-main); }"Important
为了摆脱正则替换的局限性, Petal Note 内置了一个复杂状态机驱动的轻量版 AST 抽象语法树解析渲染引擎, 足以覆盖大多数场景,
例如多重嵌套, 符号隔离, 定界符冲突, 字面量透明性, 反斜杠转义保护, 解析容错, 递归状态下的深度闭包等等
Important
最新思路:
如果想在 Hexo 之类的博客里用,可以先部署一个 Petal Note(或者只部署编辑器),然后让编辑器指向你的博客或者文章仓库,但是包括格式等很多东西都对不上。
所以需要再把编辑器的代码改一下,让它能编辑整个仓库,还能切换 .txt 和 .md 的格式,解析渲染引擎也抽离出来独立的 js,可以直接在你的个人博客里引用
Petal Note 支持基于正则表达式的复杂自定义规则
使用 regex 定义用于匹配目标文本的正则表达式模式, 可以使用捕获组 (如 ()) 来提取内容,这些捕获组可以在 replacement 字段作为变量被引用
Tip
可以使用 TOML 单引号或或者块语法避免多重转义
示例:
'^> (.*)$':匹配块引用'\[@([\w]+):([^\]]+)\]':匹配类似[@username:RealName]的提及语法,并包含两个捕获组
replacement 示例: <a class='mention' href='user/$1'>$2</a>
flags 字段用于确定匹配范围, 默认为 g, 可以组合多个标准 JavaScript RegExp 标志, 例如 gmi
以下是关于候选值的说明
"g":全局匹配, 匹配所有符合的实例"i":忽略大小写, 例如[Aa][Bb]也能匹配ab。"m":多行模式, 让^和$不仅匹配整个字符串的首尾,还能匹配每一行的首尾,通常用于处理块级结构, 如行首引用"s":点号包含换行符, 允许元字符.匹配包含换行符在内的任何字符,常用于跨行内容提取"u":Unicode 模式, 准确处理高位 Unicode 字符集 (如 Emoji)"y":粘连模式, 仅从确切的指定位置开始搜索匹配
Note
当使用正则表达式模式时, 通常也需要设置 prefix 和 suffix 字段, 此时仅生效与编辑器工具栏的文本插入功能
示例: 允许渲染十六进制背景色, 还设置了 flags = "gs" 以跨行匹配
[[rules]]
regex = '\{bg-([A-Za-z0-9#\(\),.\s]+)\}(.*?)\{/bg\}'
flags = "gs"
openTag = "{bg-"
closeTag = "{/bg}"
replacement = '<span style="background-color: color-mix(in srgb, $1, transparent 60%); padding: 0 4px; border-radius: 4px; border-bottom: 1px solid color-mix(in srgb, $1 60%, var(--text-main) 40%);">$2</span>'
group = "color"指定 open_tag 和 close_tag 字段表示开闭标签, 解析引擎将启用状态机模式, 再向下传递给 regex 处理
示例: 复刻 Markdown 标题层级语法
global_css = """
.md-h { font-family: ui-monospace, monospace; color: var(--text-main); line-height: 1.3; }
.md-h1 { font-size: 1.8rem; font-weight: 800; margin: 1.5em 0 0.8em; }
"""
[[view_templates]]
type = "icon_class"
template = '<i class="{{value}}"></i>'
[merge_groups.headings]
title = "标题层级"
icon_html = '<i class="ri-heading"></i><i class="ri-arrow-down-s-line" style="font-size: 0.8em; margin-left: -2px;"></i>'
[[rules]]
regex = "^# (.*)$"
flags = "gm"
prefix = "# "
suffix = ""
replacement = '<h1 class="md-h md-h1">$1</h1>'
group = "headings"
title = "一级标题"
view_type = "icon_class"
view_value = "ri-h-1"当语法逻辑是对称且简单的字符串包裹时,推荐使用 prefix 模式
[[rules]]
prefix = "=="
suffix = "=="
replacement = "<mark>$1</mark>"等同于:
[[rules]]
regex = '(?<!\\)==(.+?)(?<!\\)=='
flags = "gs"
replacement = "<mark>$1</mark>"仓库内预设的大多数语法都使用了简单 prefix/suffix 匹配规则, 行为和 flags = "gs" 相同, 支持跨行渲染
group: 定义组, 两个或两个以上连续的元素, 若组相同, 会在工具栏合并,
使用 [merge_groups.<group_name>] 定义组的元数据, 仅作用于工具栏上的图标
没有定义组的元数据时, 将显示一个默认图标
[[rules]]
# 其它字段...
group = "basic_styles"
title = "加粗"
view_type = "basic_styles"
view_value = "ri-bold"
# ...
[merge_groups.basic_styles]
title = "基础样式"
icon_html = """
<i class="ri-font-size-2"></i>
<i class="ri-arrow-down-s-line" style="font-size:0.8em; margin-left:-2px;"></i>
"""view_type: 图标模板, 使用 [[view_templates]], 定义一种图标模板
可以在 [[rules]] 中使用 view_type 和 view_value 指定该图标模板
-
示例: 定义一个颜色块图标
[[view_templates]] type = "color" template = """<div style="width: 16px; height: 16px; border-radius: 4px; background: {{value}}; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);"> </div>"""
-
示例: 定义一个复杂图标
还支持使用 TOML 内联表 和 TOML 数组 替换模板变量TOML 内联表: 通过
{{key}}来引用[[view_templates]] type = "simple_alert" template = "<i class='{{icon}}'></i> <span>{{label}}</span>" [[rules]] view_type = "simple_alert" view_value = { icon = "ri-info", label = "Note" }
TOML 数组: 使用
{{0}},{{1}}等索引来引用[[view_templates]] type = "status_bar" template = "状态: <b>{{0}}</b> | 负载: {{1}}%" [[rules]] view_type = "status_bar" view_value = ["运行中", 42]
-
示例: 定义一个 base64 图片图标
[[view_templates]] type = "icon_base64" template = '<img src="{{value}}" class="fmt-b64" alt="">'
-
类似的, 还可以直接定义一个
html图标[[view_templates]] type = "html" template = '{{value}}'
Tip
petal-note 仓库预设的语法文件足够应对大多数场景,可以通过 🌸 Live Demo 🌸 预览,或者单击查看文件。
有了这些玩法, 可以实现的效果非常多了, 例如以下这段示例完美复刻了 GitHub Alerts, 可以直接投入使用
点击展开代码
[[view_templates]]
type = "github_alerts"
template = '<div style="display:flex; align-items:center; gap:6px; color:{{color}}; font-size:0.85rem; font-family:ui-monospace,monospace; font-weight:600; "><i class="{{icon}}"></i><span>{{label}}</span></div>'
[merge_groups.github_alerts]
title = "插入 GitHub Alerts"
icon_html = """<i class="ri-chat-4-line"></i>"""
[[rules]]
prefix = ":::note\n"
suffix = "\n:::"
replacement = """<div class="gh-alert gh-alert-note"><div class="gh-alert-title"><i class="ri-information-line"></i> Note</div><div class="gh-alert-body">$1</div></div>"""
css = """
.gh-alert { border-left: 4px solid; padding: 0.6rem 1rem; margin: 0.8rem 0; border-radius: 0 6px 6px 0; box-shadow: 0 2px 8px rgb(0 0 0 / 2%); }
.gh-alert-title { display: flex; align-items: center; gap: 0.4rem; font-weight: 600; margin-bottom: 0.2rem; font-size: 0.9rem; }
.gh-alert-body { font-size: 0.9rem; color: var(--text-main); line-height: 1.6; }
.gh-alert-note { border-left-color: #0969da; background: rgb(9 105 218 / 4%); }
.gh-alert-note .gh-alert-title { color: #0969da; }
"""
group = "github_alerts"
title = "Note (常规提示)"
view_type = "github_alerts"
view_value = { color = "#0969da", icon = "ri-information-line", label = "Note" }
[[rules]]
prefix = ":::tip\n"
suffix = "\n:::"
replacement = """<div class="gh-alert gh-alert-tip"><div class="gh-alert-title"><i class="ri-lightbulb-line"></i> Tip</div><div class="gh-alert-body">$1</div></div>"""
css = """
.gh-alert-tip { border-left-color: #1a7f37; background: rgb(26 127 55 / 4%); }
.gh-alert-tip .gh-alert-title { color: #1a7f37; }
"""
group = "github_alerts"
title = "Tip (建议与技巧)"
view_type = "github_alerts"
view_value = { color = "#1a7f37", icon = "ri-lightbulb-line", label = "Tip" }
[[rules]]
prefix = ":::important\n"
suffix = "\n:::"
replacement = """<div class="gh-alert gh-alert-important"><div class="gh-alert-title"><i class="ri-message-3-line"></i> Important</div><div class="gh-alert-body">$1</div></div>"""
css = """
.gh-alert-important { border-left-color: #8250df; background: rgb(130 80 223 / 4%); }
.gh-alert-important .gh-alert-title { color: #8250df; }
"""
group = "github_alerts"
title = "Important (重要信息)"
view_type = "github_alerts"
view_value = { color = "#8250df", icon = "ri-message-3-line", label = "Important" }
[[rules]]
prefix = ":::warning\n"
suffix = "\n:::"
replacement = """<div class="gh-alert gh-alert-warning"><div class="gh-alert-title"><i class="ri-error-warning-line"></i> Warning</div><div class="gh-alert-body">$1</div></div>"""
css = """
.gh-alert-warning { border-left-color: #9a6700; background: rgb(154 103 0 / 4%); }
.gh-alert-warning .gh-alert-title { color: #9a6700; }
"""
group = "github_alerts"
title = "Warning (警告提示)"
view_type = "github_alerts"
view_value = { color = "#9a6700", icon = "ri-error-warning-line", label = "Warning" }
[[rules]]
prefix = ":::caution\n"
suffix = "\n:::"
replacement = """<div class="gh-alert gh-alert-caution"><div class="gh-alert-title"><i class="ri-close-circle-line"></i> Caution</div><div class="gh-alert-body">$1</div></div>"""
css = """
.gh-alert-caution { border-left-color: #d1242f; background: rgb(209 36 47 / 4%); }
.gh-alert-caution .gh-alert-title { color: #d1242f; }
"""
group = "github_alerts"
title = "Caution (危险操作)"
view_type = "github_alerts"
view_value = { color = "#d1242f", icon = "ri-close-circle-line", label = "Caution" }当正确设置 private_source 后即可生效
因为没有时间戳等约定, 秘密时间线与公开时间线相互独立. 秘密时间线通过 AES-GCM 驱动, 密语匹配成功即可进入
个人仓库中无需手动上传加密文件, petal-note 提供了 build.sh 和 cipher-thoughts.py 等实用分发工具
需要加密的文件, 例如 .env private.txt, 都可以存储为明文在你的个人仓库中, 只需在静态托管平台设置了根目录为 public
Warning
不要将仓库设置为 public, 除非你想暴露所有的东西
Tip
如果不想让人轻易获取你的网站, 可以根据个人情况为网站
添加 x-robots-tag: noindex, nofollow 标记
.
├── .env // 密语文件, 包含需要加密的文件的密码
├── .gitignore
├── editor.toml // 明文, 账户令牌和仓库信息
├── private.txt // 明文, 秘密时间线
└── public
├── config.toml // 配置文件
├── data.txt // 公开时间线
└── assets // 其它资源文件
├── favicon.ico // 图标资源
├── font.woff2 // 字体
└── ...jpg // 其他资源文件
利用 config.toml 支持 URL 的特性, 可以配置书写完成后数据即刻刷新, 并且不会触发频繁的 Deploy
需要将 托管仓库 与 数据仓库 分离, 使用 Cloudflare Workers 作为中枢代理
在 Cloudflare Worker 控制台的 Settings -> Variables 中添加以下变量
-
GITHUB_TOKEN: GitHub 访问令牌 (PAT)
示例
github_pat_xxxxxxxxx
Warning
令牌使用 Fine-grained personal access tokens 生成, 并在此处设置为 机密
-
ENCRYPT_MAP: AES-GCM 密码和文件映射字典
示例
{ "private.txt": "your_private_password", "editor.toml": "your_editor_password" }
Warning
文件路径直接对应 GitHub 仓库路径, 务必将敏感文件添加到字典, 字典外的其余文件均明文返回
该 JSON 应设置为 机密
- GITHUB_USER: GitHub 用户名
- GITHUB_REPO: 数据仓库的私有仓库名
以下为 worker.js, 直接返回仓库文件, 并根据字典名单机制进行加密
点击展开代码
export default {
async fetch(r, e) {
const h = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Content-Type": "text/plain;charset=UTF-8" };
if (r.method === "OPTIONS") return new Response(null, { headers: h });
const p = new URL(r.url).pathname.replace(/\/+/g, '/').slice(1);
if (!p) return new Response("Please specify a file path", { status: 400, headers: h });
const f = async (t) => {
const s = await fetch(`https://api.github.com/repos/${e.GITHUB_USER}/${e.GITHUB_REPO}/contents/${t}`, {
headers: { Authorization: `Bearer ${e.GITHUB_TOKEN}`, Accept: "application/vnd.github.v3.raw", "User-Agent": "CF-Worker" }
});
if (!s.ok) throw new Error(s.status === 404 ? "Not Found" : `Failed to fetch ${t}: ${s.status} ${s.statusText}`);
return await s.text();
};
const c = async (t, k) => {
const n = new TextEncoder(), d = await crypto.subtle.digest("SHA-256", n.encode(k));
const y = await crypto.subtle.importKey("raw", d, { name: "AES-GCM" }, false, ["encrypt"]);
const i = crypto.getRandomValues(new Uint8Array(12));
const b = await crypto.subtle.encrypt({ name: "AES-GCM", iv: i }, y, n.encode(t));
const a = new Uint8Array(b), o = new Uint8Array(12 + a.length);
o.set(i); o.set(a, 12);
return btoa(o.reduce((x, v) => x + String.fromCharCode(v), ""));
};
try {
const t = await f(p);
let m = {};
if (e.ENCRYPT_MAP) try { m = JSON.parse(e.ENCRYPT_MAP); } catch (_) { }
return new Response(m[p] ? await c(t, m[p]) : t, { headers: h });
} catch (x) {
return new Response(x.message, { status: x.message === "Not Found" ? 404 : 500, headers: h });
}
}
};配置完成后, 在 config.toml 中直接将数据源指向你的 Worker 地址即可:
data_source = "https://your-worker.workers.dev/data.txt"
private_source = "https://your-worker.workers.dev/private.txt"
editor_config = "https://your-worker.workers.dev/editor.toml"每次返回的加密文件都是不同的
也可以在数据仓库中设置一个 GitHub Actions, 即推送后自动生成加密文件, 这样可以节省 cloudflare cpu 时间,虽然似乎没有必要这样做
完成这一步后, 你的托管仓库就非常干净了, 仓库升级为统一调度中心, 核心仅一个 config.toml
资源文件也可以放进任何位置, 推荐使用 Cloudflare R2 进行存储
.
└── public
└── config.toml
↑
icon = "https://your-r2.com/favicon.ico" # 云存储
font = "https://your-worker.com/font.woff2" # 存储在数据仓库, 通过 worker 读取
res_config = "./assets/file.ext" # 存储在托管仓库
但似乎无法做到每次写日记时拉取最新骨架了, 因为只有 petal-note 仓库所有者能够设置骨架更新时触发静态托管平台的钩子
手动点 Redeploy? 绝对不行
Warning
如果让数据仓库和托管仓库在一起, 同时设置 worker
但那样就可以直接通过 worker 获取任意明文了
解决方案: 在你的静态平台上拿到一个触发 Deploy 的钩子, 然后在任意位置设置定时任务, 即可保持骨架自动更新
若完成以上步骤, 只需要在网页中打开编辑器, 点点保存, 前端即可立即更新日记
⭐ 那么恭喜你, 已经完全应用了无服务器架构的玩法, 享受它!
KEY_private_source="admin"
KEY_editor_config="admin"
PASSWORD=""Build Command 分发时优先使用 KEY_<config_name>, 否则回退到 PASSWORD
用于定义站点的全局信息, 所有项均为可选
包括 data_source, private_source 的资源可以设置为网络 URL, 运行时将自动拉取
data_source = "./data.txt"
private_source = "./private.txt"
private_tip = "" # 回退: '输入轻语解锁梦境...'
editor_config = "./editor.toml"
editor_unlocktip = "" # 回退: '输入轻语解锁时序...'
home_url = "https://github.com/miniyu157/petal-note"
font = "./assets/font.woff2"
title = "Petal"
header_title = "Petal Note"
header_subtitle = """
风吹落的花瓣,和那些无处安放的碎碎念。
"""
icon = "./assets/favicon.ico"
theme_color = "#FFB6C1"
unread_empty_tip = "所有的花瓣都已读过了,去吹吹风吧。"
private_title = ""
private_header_title = ""
private_header_subtitle = ""
private_icon = ""
private_theme_color = ""
private_unread_empty_tip = ""
load_delay = 800 # ms
data_order = "asc" # [asc|desc]使用 --- 作为每条日记的分割线
2026-02-18 19:40
#日记 #碎碎念 在这里种下一颗种子, 希望能开出温柔的花。
不问花期, 只愿过程静好。
---
2026-02-18 深夜
长内容会自动检测高度并在底部呈现渐隐折叠。

支持简单的 Markdown 图片语法, 以及直接写入的 https:// 链接, 它们会被自动解析并高亮。
仓库中的 cipher-thoughts.py 是一个极简的 AES-GCM 工具, 由 python 库 cryptography 驱动
每次加密时生成 12 字节的 IV, 所以即使内容和密码相同, 每次的密文也是不同的
> ./cipher-thoughts.py
usage: cipher-thoughts.py [-d] [-t TEXT] [-f FILE][-o [OUT]] [-O [OVERWRITE_OUT]] [-p PASSWORD] [-h] [filepath]
极简 AES-GCM 工具
positional arguments:
filepath 要处理的文件
options:
-d, --decrypt 解密模式
-t, --text TEXT 直接处理传入的文本内容
-f, --file FILE 处理指定路径的文件 (同位置参数)
-o, --out [OUT] 将结果输出到文件 (不指定文件名则自动去除或加入 .dec 后缀)
-O, --overwrite-out [OVERWRITE_OUT]
将结果输出到文件 (不指定文件名则自动去除或加入 .dec 后缀, 不检查覆盖)
-p, --password PASSWORD
指定密码 (优先于环境变量及.env)
-h, --help 显示此帮助信息并退出