原文:How to build a static site with Lume,Óscar Otero(Lume 创作者),Deno Blog,2022 年 12 月 7 日。本文对原教程完整技术流程作中文整理,并单独标明版本与安全补充。所有命令和代码均只做静态核对,未实际运行。
博客、文档和介绍页面不一定需要服务器为每次请求生成内容。静态站点先把内容构建成 HTML、CSS、JavaScript,再把这些文件交给浏览器。Lume 是建立在 Deno 上的静态站点生成器,读音为 /lume/,强调灵活的格式支持、组合能力和扩展方式。
原教程从现成主题开始,再展示如何自己组织文章、共享数据、详情页布局、首页列表和样式,最后通过 GitHub Actions 构建并上传到当时的 Deno Deploy。这是一篇 2022 年教程,不能把其中 Deno 1.x、旧部署界面及依赖版本直接当作 2026 年的新建项目规范。

初始化:先分清历史命令与当前入口
原文的初始化命令如下,保留它是为了说明教程所处的版本环境:
# 原文 2022 年历史命令;不要直接用于当前生产项目
deno run -Ar --unstable https://deno.land/x/lume/init.ts
它会在工作目录创建 _config.ts、deno.json 和 import_map.json。前者配置 Lume,第二个文件保存 Deno 配置与任务,第三个把裸模块名解析为实际模块地址。原文的目录树有一处写成 config.ts,本文统一使用正文所指的 _config.ts,这是编辑纠正。
截至本稿核验时,Lume 官方安装页给出的初始化入口是 deno run -A https://lume.land/init.ts;页面示例使用 Lume 3.3.2,并把 imports 写在 deno.json 中。它不是对旧项目无条件兼容的替换指令。新建项目应先按该文档生成所选版本配置,再核对模板、插件及迁移说明;本文不会把原教程部分旧 API 与部分新 API 拼成未经验证的“升级版”。
权限说明:-A 允许全部 Deno 权限,旧命令中的 -r 会重新获取依赖。初始化脚本是远程代码。应在没有凭据和个人文件的临时项目目录中检查来源、固定可审计版本后运行;不能仅因使用 Deno 就推断这条命令受到最小权限保护。本文没有执行它。
最快的方式:采用博客主题
原文用 Simple blog 主题演示最短路径。在该旧版项目的 _config.ts 中导入 Lume 与固定版本的主题,把主题作为插件接入:
// 原文使用的版本与导入写法
import lume from "lume";
import blog from "https://deno.land/x/lume_theme_simple_blog@v0.2.1/mod.ts";
const site = lume().use(blog());
export default site;
把文章放在 posts/ 目录,以 Markdown 正文配合 YAML front matter 记录标题、日期、作者和标签。例如:
---
title: Static sites with Lume + Deno Deploy
date: 2022-11-05
author: Óscar Otero
tags:
- Deno
- Static site generators
---
这里写文章正文。
原文运行 deno task serve 后在 http://localhost:3000 查看博客。这里的地址是教程示例,实际监听端口和地址须以所选版本输出为准。主题的默认结构和样式足够时,剩下的主要工作就是维护文章内容。
自己搭建:从文章文件开始
Lume 并不要求项目必须采取某个文件结构;原教程选用以下组织方式:
posts/
my-first-post.md
my-second-post.md
_config.ts
deno.json
import_map.json
此时启动开发服务,站点根路径会显示 404,因为尚未建立首页。文章本身却已存在:例如 posts/my-first-post.md 默认对应 /posts/my-first-post/。这区分了“站点没有首页”和“文章没有生成”两件事。
用布局包住 Markdown 内容
直接生成的 Markdown HTML 缺少完整页面骨架。原文在特殊目录 _includes/ 下创建 post.njk,以 Nunjucks 模板读取文章数据。以下保留核心字段,省去已无必要的旧 IE 兼容元标签,并把文档语言改为中文,这是本文的展示性调整:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
</head>
<body>
<main>
<article>
<header>
<h1>{{ title }}</h1>
<p>作者:{{ author }}</p>
</header>
{{ content | safe }}
</article>
</main>
</body>
</html>
.njk 表示 Nunjucks。原教程所用 Lume 版本默认支持它,也可以通过插件使用 JavaScript、TypeScript、JSX、Pug、Eta 等格式。当前项目要按选定版本确认引擎是否需要显式启用。
为了让所有文章使用同一个布局,在 posts/_data.yml 中写入:
layout: post.njk
type: post
目录级共享数据使该目录的文章都获得这两个字段。layout 指向 _includes/post.njk;type: post 则是后面检索文章的标记。模板还能直接读取各篇 front matter 中的 title、author 等值。
信任边界:content | safe 告诉模板引擎把内容当作已经可信的 HTML。它不是 HTML 清洗器。若 Markdown 或富文本来自不可信投稿,需要先进行可靠的 HTML 清洗,并限制允许的元素、属性和 URL;单纯把内容标为 safe 会让恶意标记进入最终页面。
首页如何找到全部文章
原文在根目录创建 index.tmpl.ts,导出一个返回字符串的默认函数。函数的第一个参数是页面上下文,其中的 search 辅助工具可以检索页面:
// 原文核心写法,适用于可信作者维护的内容
import type { PageData } from "lume/core.ts";
export const layout = "homepage.njk";
export default function ({ search }: PageData) {
const posts = search.pages("type=post");
return `
<h2>Posts</h2>
<ul>${posts.map((post) =>
`<li><a href="${post.data.url}">${post.data.title}</a></li>`
).join("")}</ul>
`;
}
search.pages("type=post") 正好对应共享数据中的类型标记。默认导出控制页面正文,命名导出的 layout 控制使用哪个布局;TypeScript 文件通过这种导出方式表达 Markdown 文件里通常写在 front matter 中的数据。
原文先展示未使用布局的列表,再增加 homepage.njk。下面是该首页布局的中文展示版本:
---
title: 我的博客
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
</head>
<body>
<main>
<header><h1>{{ title }}</h1></header>
{{ content | safe }}
</main>
</body>
</html>
直接拼接 HTML 的风险与局部修正
原 TypeScript 列表把标题和 URL 原样插入字符串。若允许不可信数据进入这些字段,标题能注入 HTML,链接也可能逃出属性或使用危险协议。下面是本文新增的局部加固写法,替换原文的列表生成函数体;它仅允许站内根相对 URL,对文本和属性值做转义。该修正未经 Lume 执行测试,不等于完整内容净化方案。
function escapeHtml(value: unknown): string {
const entities: Record<string, string> = {
"&": "&", "<": "<", ">": ">",
'"': """, "'": "'",
};
return String(value).replace(/[&<>"']/g, (c) => entities[c]!);
}
export default function ({ search }: PageData) {
const items = search.pages("type=post").map((post) => {
const url = String(post.data.url);
if (!/^\/(?!\/)/.test(url) || /[\\\u0000-\u0020]/.test(url)) {
throw new Error("Expected a site-root-relative post URL");
}
return `<li><a href="${escapeHtml(url)}">${escapeHtml(post.data.title)}</a></li>`;
});
return `<h2>文章</h2><ul>${items.join("")}</ul>`;
}
这个片段沿用上一个文件中的 PageData 导入与 layout 导出。它也改变了原文可接受的 URL 范围:绝对外链会被拒绝。若业务需要外链,应显式限制为允许的协议与站点,而不是直接删掉验证。
添加样式
Lume 提供多种处理样式及内容格式的插件。原教程为了保持简单,选择 missing.css,在两个布局中增加:
<link rel="stylesheet" href="https://the.missing.style/">
这是未固定内容版本的第三方资源。需要可重复构建或严格内容安全策略的站点,可以选择审查并固定版本后把样式自托管,再通过项目所选版本的静态资源机制复制到输出目录;这样样式不会在未重新发布时自行变化。本文没有下载或运行这个外部样式。
发布链路:先构建,再上传产物
静态站点的部署对象是构建后的文件。原教程在当时的 Deno Deploy 中创建项目,连接 GitHub 仓库,并选用 GitHub Action 模式,因为当时该部署流程不负责执行站点构建。再把平台生成的配置保存为 .github/workflows/deploy.yml,补上 Deno 环境和 deno task build,把服务入口设为标准库的文件服务器,根目录设为 ./_site。
以下是原文工作流,仅作为 2022 年历史配置保留,不是推荐直接提交的当前部署文件:
name: Deploy
on: [push]
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Clone repository
uses: actions/checkout@v3
- name: Setup Deno environment
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Build site
run: deno task build
- name: Upload to Deno Deploy
uses: denoland/deployctl@v1
with:
project: oscarotero-lume-blog-demo
entrypoint: https://deno.land/std@0.167.0/http/file_server.ts
root: ./_site
contents: read 用于检出仓库,id-token: write 用于身份认证。项目名属于原作者示例,不应被复制为你的部署目标。on: [push] 会响应各分支推送;正式环境应限制受信任分支与发布环境,并让部署凭据只在必要步骤可用。Actions 版本、Deno 版本、标准库入口和 Deno Deploy 产品流程都需按实际环境重新核对,第三方 Action 可固定到经审核的提交。
若需要缓存过期或自定义 404 等 Lume 中间件,原文建议另建 serve.ts 接入相应中间件,再把它设为部署入口。这改变的是静态文件如何响应请求,不改变“先生成 _site,再部署产物”的基本步骤。原文最后在 *.deno.dev 或自定义域名访问成品;本稿没有创建任何线上项目。
理解这条内容生产链
文章文件保存内容,front matter 和共享数据保存元信息,布局决定页面外壳,首页通过检索把文章汇集起来,构建任务生成发布目录。理解这些职责后,就能逐项定位问题:路径不对查输出 URL,标题不对查数据,页面骨架不对查布局,发布后缺文件查构建产物与部署根目录。
可继续查阅原文示例项目、Lume 文档及当前安装说明。本文保留完整原教程流程,但没有宣称旧版本示例已升级或已经在当前服务上运行成功。
署名与许可:原作者 Óscar Otero,Deno Blog。原博客页未另行声明开放内容许可证。本文的目录拼写纠正、中文模板、HTML 转义片段和安全提醒为编辑补充;配图为原创示意图。核验日期:2026-10-05。
原文图示






















暂无评论内容