[{"data":1,"prerenderedAt":3904},["ShallowReactive",2],{"article-index":3,"project-index":3431},[4,1018,1588,1663,1924,2125,2212,2889,3279,3359],{"id":5,"title":6,"accent":7,"author":8,"body":9,"canonical":999,"category":1000,"cover":1001,"date":1002,"description":1003,"draft":1004,"extension":1005,"indexable":251,"meta":1006,"navigation":251,"path":999,"readingTime":1007,"seo":1008,"seoDescription":1009,"seoTitle":1010,"stem":1011,"tags":1012,"updated":1002,"__hash__":1017},"articles\u002Farticles\u002Fcodepaper-safe-draft-handoff.md","从浏览器草稿到 Git 发布：CodePaper 的安全内容交接","#8b7cff","Echo",{"type":10,"value":11,"toc":988},"minimark",[12,16,19,27,31,34,37,48,51,65,68,71,107,117,144,164,167,178,185,383,392,395,399,402,495,498,501,737,740,744,747,803,810,813,816,819,870,873,889,892,895,898,942,945,948,951,972,975,978,981,984],[13,14,15],"p",{},"浏览器里的编辑器适合自由写作，Git 仓库里的内容文件适合审查和发布。问题出现在两者之间：如果编辑器只导出正文，作者需要手工补齐十几个 Frontmatter 字段；如果编辑器直接写入正式内容目录，一次误操作就可能把半成品带进 sitemap、站内搜索和自动部署。",[13,17,18],{},"CodePaper 最初只提供便携 Markdown 导出。它能保住正文，却不能直接满足 EchoBlog 的文章 schema。真实写完一篇关于 Markdown 安全预览的技术文章后，这个缺口变得很具体：标题和标签之外，还缺少日期、作者、分类、封面、阅读时间、索引状态、SEO 摘要和规范地址。",[13,20,21,22,26],{},"这次实现没有把 CodePaper 扩展成 CMS，而是在浏览器与仓库之间增加一份安全、完整、仍然需要审查的 ",[23,24,25],"code",{},"*.draft.md"," 文件。本文记录这条内容交接链路，并用本文自身完成一次从草稿到正式发布的验证。",[28,29,30],"h2",{"id":30},"先定义发布边界",[13,32,33],{},"安全草稿导出解决的是格式转换，不是发布权限。CodePaper 仍然只在当前浏览器保存正文，不持有 Git 凭证，也不调用服务端写文件接口。",[13,35,36],{},"完整链路被拆成五步：",[38,39,46],"pre",{"className":40,"code":42,"filename":43,"language":44,"meta":45},[41],"language-text","浏览器本地草稿\n  -> CodePaper 字段校验\n  -> 下载 *.draft.md\n  -> 人工复核并放入 content\u002Farticles\n  -> 内容、链接、测试与构建门禁\n  -> Git 提交和自动部署\n","publishing-pipeline","text","",[23,47,42],{"__ignoreMap":45},[13,49,50],{},"每一步只承担一种职责。编辑器负责生成结构正确的候选文件，仓库负责审查差异，Nuxt Content 负责 schema，项目脚本负责更严格的业务规则，CI 才拥有部署权限。这样即使浏览器状态、输入字段或正文存在问题，也不会直接越过后续门禁。",[13,52,53,60,61,64],{},[54,55,59],"a",{"href":56,"rel":57},"https:\u002F\u002Fcontent.nuxt.com\u002Fdocs\u002Fcollections\u002Fdefine",[58],"nofollow","Nuxt Content 的集合文档","把 collection schema 定义为内容一致性与 TypeScript 类型的来源。EchoBlog 因此不在 CodePaper 中复制一套“差不多”的字段，而是按当前 ",[23,62,63],{},"content.config.ts"," 生成完整 Frontmatter，再交给同一份 schema 复核。",[28,66,67],{"id":67},"默认值必须偏向不发布",[13,69,70],{},"导出时最重要的两个字段不是标题，而是：",[38,72,77],{"className":73,"code":74,"filename":75,"language":76,"meta":45,"style":45},"language-yaml shiki shiki-themes github-light github-dark","draft: true\nindexable: false\n","safe-defaults","yaml",[23,78,79,96],{"__ignoreMap":45},[80,81,84,88,92],"span",{"class":82,"line":83},"line",1,[80,85,87],{"class":86},"s9eBZ","draft",[80,89,91],{"class":90},"sVt8B",": ",[80,93,95],{"class":94},"sj4cs","true\n",[80,97,99,102,104],{"class":82,"line":98},2,[80,100,101],{"class":86},"indexable",[80,103,91],{"class":90},[80,105,106],{"class":94},"false\n",[13,108,109,112,113,116],{},[23,110,111],{},"draft: true"," 表示文件还没有通过人工复核，文件名也必须保留 ",[23,114,115],{},".draft.md","。生产环境会排除这类文件，因此即使它被提前放进内容目录，也不会成为正式页面。",[13,118,119,122,123,125,126,131,132,135,136,139,140,143],{},[23,120,121],{},"indexable: false"," 是第二层保险。它不是 ",[23,124,87],{}," 的替代品，而是避免文件在错误改名、调试预览或发布状态调整时立刻进入搜索索引。",[54,127,130],{"href":128,"rel":129},"https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fcrawling-indexing\u002Frobots-meta-tag",[58],"Google 的 robots meta 文档","说明，爬虫必须能够访问页面，才能读取 ",[23,133,134],{},"noindex"," 指令；因此 EchoBlog 不用 ",[23,137,138],{},"robots.txt"," 阻止这类页面，而是在页面元数据中输出 ",[23,141,142],{},"noindex, follow","。",[13,145,146,147,150,151,153,154,157,158,153,160,163],{},"这两个状态需要人工分别解除。正式发布时，作者必须明确把文件名改为普通 ",[23,148,149],{},".md","，再把 ",[23,152,87],{}," 改成 ",[23,155,156],{},"false","；确认内容应该被搜索引擎发现后，才把 ",[23,159,101],{},[23,161,162],{},"true","。显式操作比猜测作者意图更可靠。",[28,165,166],{"id":166},"用白名单连接站点分类",[13,168,169,170,173,174,177],{},"分类和标签不是任意字符串。EchoBlog 的 ",[23,171,172],{},"\u002Ftags\u002F\u003Cslug>","、",[23,175,176],{},"\u002Fcategories\u002F\u003Cslug>","、首页计数和相关文章都依赖统一 taxonomy。如果 CodePaper 允许随意输入，“TypeScript”“typescript”和“TS”很容易变成三组内容。",[13,179,180,181,184],{},"导出逻辑只接受 ",[23,182,183],{},"shared\u002Ftaxonomy.ts"," 已登记的分类与标签：",[38,186,191],{"className":187,"code":188,"filename":189,"language":190,"meta":45,"style":45},"language-ts shiki shiki-themes github-light github-dark","const categoryDefinition = findCategoryDefinition(input.category)\nconst tagDefinitions = tags.map((tag) => findTagDefinition(tag))\n\nif (!categoryDefinition) {\n  throw new Error(`分类「${input.category}」未在站点注册表中登记`)\n}\n\nconst unknownTag = tags.find((_, index) => !tagDefinitions[index])\nif (unknownTag) {\n  throw new Error(`标签「${unknownTag}」未在站点注册表中登记`)\n}\n","taxonomy-validation","ts",[23,192,193,212,246,253,268,302,308,313,349,357,378],{"__ignoreMap":45},[80,194,195,199,202,205,209],{"class":82,"line":83},[80,196,198],{"class":197},"szBVR","const",[80,200,201],{"class":94}," categoryDefinition",[80,203,204],{"class":197}," =",[80,206,208],{"class":207},"sScJk"," findCategoryDefinition",[80,210,211],{"class":90},"(input.category)\n",[80,213,214,216,219,221,224,227,230,234,237,240,243],{"class":82,"line":98},[80,215,198],{"class":197},[80,217,218],{"class":94}," tagDefinitions",[80,220,204],{"class":197},[80,222,223],{"class":90}," tags.",[80,225,226],{"class":207},"map",[80,228,229],{"class":90},"((",[80,231,233],{"class":232},"s4XuR","tag",[80,235,236],{"class":90},") ",[80,238,239],{"class":197},"=>",[80,241,242],{"class":207}," findTagDefinition",[80,244,245],{"class":90},"(tag))\n",[80,247,249],{"class":82,"line":248},3,[80,250,252],{"emptyLinePlaceholder":251},true,"\n",[80,254,256,259,262,265],{"class":82,"line":255},4,[80,257,258],{"class":197},"if",[80,260,261],{"class":90}," (",[80,263,264],{"class":197},"!",[80,266,267],{"class":90},"categoryDefinition) {\n",[80,269,271,274,277,280,283,287,290,293,296,299],{"class":82,"line":270},5,[80,272,273],{"class":197},"  throw",[80,275,276],{"class":197}," new",[80,278,279],{"class":207}," Error",[80,281,282],{"class":90},"(",[80,284,286],{"class":285},"sZZnC","`分类「${",[80,288,289],{"class":90},"input",[80,291,292],{"class":285},".",[80,294,295],{"class":90},"category",[80,297,298],{"class":285},"}」未在站点注册表中登记`",[80,300,301],{"class":90},")\n",[80,303,305],{"class":82,"line":304},6,[80,306,307],{"class":90},"}\n",[80,309,311],{"class":82,"line":310},7,[80,312,252],{"emptyLinePlaceholder":251},[80,314,316,318,321,323,325,328,330,333,336,339,341,343,346],{"class":82,"line":315},8,[80,317,198],{"class":197},[80,319,320],{"class":94}," unknownTag",[80,322,204],{"class":197},[80,324,223],{"class":90},[80,326,327],{"class":207},"find",[80,329,229],{"class":90},[80,331,332],{"class":232},"_",[80,334,335],{"class":90},", ",[80,337,338],{"class":232},"index",[80,340,236],{"class":90},[80,342,239],{"class":197},[80,344,345],{"class":197}," !",[80,347,348],{"class":90},"tagDefinitions[index])\n",[80,350,352,354],{"class":82,"line":351},9,[80,353,258],{"class":197},[80,355,356],{"class":90}," (unknownTag) {\n",[80,358,360,362,364,366,368,371,374,376],{"class":82,"line":359},10,[80,361,273],{"class":197},[80,363,276],{"class":197},[80,365,279],{"class":207},[80,367,282],{"class":90},[80,369,370],{"class":285},"`标签「${",[80,372,373],{"class":90},"unknownTag",[80,375,298],{"class":285},[80,377,301],{"class":90},[80,379,381],{"class":82,"line":380},11,[80,382,307],{"class":90},[13,384,385,386,391],{},"界面使用选择控件展示注册表，而共享函数仍会再次检查输入。这不是多余重复：浏览器界面负责减少误操作，共享导出函数负责保护数据边界。",[54,387,390],{"href":388,"rel":389},"https:\u002F\u002Fcheatsheetseries.owasp.org\u002Fcheatsheets\u002FInput_Validation_Cheat_Sheet.html",[58],"OWASP 的输入校验指南","也建议对有限集合采用 allowlist，并强调客户端校验不能替代可信边界上的校验。",[13,393,394],{},"别名会被归一为注册表里的正式展示名，重复标签会被去除。文章、项目、标签页和 DIY Tags 因此继续读取同一份事实来源，不需要再修正手工计数。",[28,396,398],{"id":397},"frontmatter-完整不等于输入原样拼接","Frontmatter 完整不等于输入原样拼接",[13,400,401],{},"安全导出需要同时规范化结构化字段和自由正文。当前实现对输入做了几类处理：",[403,404,405,421],"table",{},[406,407,408],"thead",{},[409,410,411,415,418],"tr",{},[412,413,414],"th",{},"输入",[412,416,417],{},"处理方式",[412,419,420],{},"目的",[422,423,424,436,451,462,473,484],"tbody",{},[409,425,426,430,433],{},[427,428,429],"td",{},"slug",[427,431,432],{},"只允许小写字母、数字和单个连字符",[427,434,435],{},"保持稳定路由与文件名",[409,437,438,441,448],{},[427,439,440],{},"日期",[427,442,443,444,447],{},"严格验证 ",[23,445,446],{},"YYYY-MM-DD"," 和真实日期",[427,449,450],{},"拒绝会被 JavaScript 自动纠正的无效日期",[409,452,453,456,459],{},[427,454,455],{},"封面",[427,457,458],{},"只接受站内根路径或 HTTPS URL",[427,460,461],{},"阻止相对路径逃逸和公开 HTTP",[409,463,464,467,470],{},[427,465,466],{},"强调色",[427,468,469],{},"只接受六位十六进制颜色",[427,471,472],{},"避免任意 CSS 值进入主题",[409,474,475,478,481],{},[427,476,477],{},"分类\u002F标签",[427,479,480],{},"taxonomy 白名单归一",[427,482,483],{},"保持导航和计数一致",[409,485,486,489,492],{},[427,487,488],{},"标题与摘要",[427,490,491],{},"去除多余空白并限制长度",[427,493,494],{},"保持列表、SEO 与 YAML 可控",[13,496,497],{},"YAML 字符串统一使用单引号，并把内容中的单引号转义为两个单引号，避免标题里的冒号、井号或引号破坏 Frontmatter。",[13,499,500],{},"正文也不能简单追加。用户可能导入一个已经带 Frontmatter 的文件，或者在正文第一行重复写了与元数据相同的一级标题。导出时会移除现有 Frontmatter，并在标题一致时去掉重复 H1，保留从二级标题开始的文章结构：",[38,502,505],{"className":187,"code":503,"filename":504,"language":190,"meta":45,"style":45},"if (\u002F^---\\s*\\n\u002Fu.test(body)) {\n  body = body.replace(\u002F^---\\s*\\n[\\s\\S]*?\\n---\\s*(?:\\n|$)\u002Fu, '').trim()\n}\n\nconst firstHeading = body.match(\u002F^#\\s+(.+?)\\s*(?:\\n|$)\u002Fu)\nif (firstHeading && normalizeCodePaperTitle(firstHeading[1]) === title) {\n  body = body.slice(firstHeading[0].length).trim()\n}\n","body-normalization",[23,506,507,545,614,618,622,677,705,733],{"__ignoreMap":45},[80,508,509,511,513,516,519,523,526,529,532,534,537,539,542],{"class":82,"line":83},[80,510,258],{"class":197},[80,512,261],{"class":90},[80,514,515],{"class":285},"\u002F",[80,517,518],{"class":197},"^",[80,520,522],{"class":521},"sA_wV","---",[80,524,525],{"class":94},"\\s",[80,527,528],{"class":197},"*",[80,530,531],{"class":94},"\\n",[80,533,515],{"class":285},[80,535,536],{"class":197},"u",[80,538,292],{"class":90},[80,540,541],{"class":207},"test",[80,543,544],{"class":90},"(body)) {\n",[80,546,547,550,553,556,559,561,563,565,567,569,571,574,577,579,581,583,585,588,590,593,596,598,600,602,605,608,611],{"class":82,"line":98},[80,548,549],{"class":90},"  body ",[80,551,552],{"class":197},"=",[80,554,555],{"class":90}," body.",[80,557,558],{"class":207},"replace",[80,560,282],{"class":90},[80,562,515],{"class":285},[80,564,518],{"class":197},[80,566,522],{"class":521},[80,568,525],{"class":94},[80,570,528],{"class":197},[80,572,573],{"class":94},"\\n[\\s\\S]",[80,575,576],{"class":197},"*?",[80,578,531],{"class":94},[80,580,522],{"class":521},[80,582,525],{"class":94},[80,584,528],{"class":197},[80,586,587],{"class":521},"(?:",[80,589,531],{"class":94},[80,591,592],{"class":197},"|$",[80,594,595],{"class":521},")",[80,597,515],{"class":285},[80,599,536],{"class":197},[80,601,335],{"class":90},[80,603,604],{"class":285},"''",[80,606,607],{"class":90},").",[80,609,610],{"class":207},"trim",[80,612,613],{"class":90},"()\n",[80,615,616],{"class":82,"line":248},[80,617,307],{"class":90},[80,619,620],{"class":82,"line":255},[80,621,252],{"emptyLinePlaceholder":251},[80,623,624,626,629,631,633,636,638,640,642,645,647,650,652,654,657,659,661,663,665,667,669,671,673,675],{"class":82,"line":270},[80,625,198],{"class":197},[80,627,628],{"class":94}," firstHeading",[80,630,204],{"class":197},[80,632,555],{"class":90},[80,634,635],{"class":207},"match",[80,637,282],{"class":90},[80,639,515],{"class":285},[80,641,518],{"class":197},[80,643,644],{"class":521},"#",[80,646,525],{"class":94},[80,648,649],{"class":197},"+",[80,651,282],{"class":521},[80,653,292],{"class":94},[80,655,656],{"class":197},"+?",[80,658,595],{"class":521},[80,660,525],{"class":94},[80,662,528],{"class":197},[80,664,587],{"class":521},[80,666,531],{"class":94},[80,668,592],{"class":197},[80,670,595],{"class":521},[80,672,515],{"class":285},[80,674,536],{"class":197},[80,676,301],{"class":90},[80,678,679,681,684,687,690,693,696,699,702],{"class":82,"line":304},[80,680,258],{"class":197},[80,682,683],{"class":90}," (firstHeading ",[80,685,686],{"class":197},"&&",[80,688,689],{"class":207}," normalizeCodePaperTitle",[80,691,692],{"class":90},"(firstHeading[",[80,694,695],{"class":94},"1",[80,697,698],{"class":90},"]) ",[80,700,701],{"class":197},"===",[80,703,704],{"class":90}," title) {\n",[80,706,707,709,711,713,716,718,721,724,727,729,731],{"class":82,"line":310},[80,708,549],{"class":90},[80,710,552],{"class":197},[80,712,555],{"class":90},[80,714,715],{"class":207},"slice",[80,717,692],{"class":90},[80,719,720],{"class":94},"0",[80,722,723],{"class":90},"].",[80,725,726],{"class":94},"length",[80,728,607],{"class":90},[80,730,610],{"class":207},[80,732,613],{"class":90},[80,734,735],{"class":82,"line":315},[80,736,307],{"class":90},[13,738,739],{},"这让重新导入、修改和再次导出保持幂等，不会每经过一次工具就多一层元数据或标题。",[28,741,743],{"id":742},"稳定-slug-与草稿文件名","稳定 slug 与草稿文件名",[13,745,746],{},"英文标题可以生成可读 slug，中文标题无法可靠自动转写时则使用带日期的确定性回退：",[38,748,751],{"className":187,"code":749,"filename":750,"language":190,"meta":45,"style":45},"const slug = createCodePaperBlogSlug(title, date)\n\u002F\u002F codepaper-safe-draft-handoff\n\u002F\u002F 或 codepaper-20260728\n\nconst filename = createCodePaperBlogFilename(slug)\n\u002F\u002F codepaper-safe-draft-handoff.draft.md\n","slug-fallback",[23,752,753,768,774,779,783,798],{"__ignoreMap":45},[80,754,755,757,760,762,765],{"class":82,"line":83},[80,756,198],{"class":197},[80,758,759],{"class":94}," slug",[80,761,204],{"class":197},[80,763,764],{"class":207}," createCodePaperBlogSlug",[80,766,767],{"class":90},"(title, date)\n",[80,769,770],{"class":82,"line":98},[80,771,773],{"class":772},"sJ8bj","\u002F\u002F codepaper-safe-draft-handoff\n",[80,775,776],{"class":82,"line":248},[80,777,778],{"class":772},"\u002F\u002F 或 codepaper-20260728\n",[80,780,781],{"class":82,"line":255},[80,782,252],{"emptyLinePlaceholder":251},[80,784,785,787,790,792,795],{"class":82,"line":270},[80,786,198],{"class":197},[80,788,789],{"class":94}," filename",[80,791,204],{"class":197},[80,793,794],{"class":207}," createCodePaperBlogFilename",[80,796,797],{"class":90},"(slug)\n",[80,799,800],{"class":82,"line":304},[80,801,802],{"class":772},"\u002F\u002F codepaper-safe-draft-handoff.draft.md\n",[13,804,805,806,809],{},"自动生成只是建议，导出前仍允许作者修改。校验要求整个 slug 匹配 ",[23,807,808],{},"^[a-z0-9]+(?:-[a-z0-9]+)*$","，并限制为 80 个字符。截断后会再次去除末尾连字符，避免边界长度生成不合法文件名。",[13,811,812],{},"slug 一旦发布就不应该随意改变。Nuxt Content 的 page collection 会根据文件路径生成页面路径，因此文件名既是内容标识，也是 canonical、站内链接和搜索引擎地址的一部分。",[28,814,815],{"id":815},"让门禁而不是界面决定能否发布",[13,817,818],{},"CodePaper 导出成功只表示候选文件结构合理，不能表示文章已经达到发布质量。草稿进入仓库后还需要执行：",[38,820,825],{"className":821,"code":822,"filename":823,"language":824,"meta":45,"style":45},"language-bash shiki shiki-themes github-light github-dark","pnpm validate:content\npnpm check:links\npnpm test\npnpm typecheck\npnpm build\npnpm check:budget\n","release-gates","bash",[23,826,827,835,842,849,856,863],{"__ignoreMap":45},[80,828,829,832],{"class":82,"line":83},[80,830,831],{"class":207},"pnpm",[80,833,834],{"class":285}," validate:content\n",[80,836,837,839],{"class":82,"line":98},[80,838,831],{"class":207},[80,840,841],{"class":285}," check:links\n",[80,843,844,846],{"class":82,"line":248},[80,845,831],{"class":207},[80,847,848],{"class":285}," test\n",[80,850,851,853],{"class":82,"line":255},[80,852,831],{"class":207},[80,854,855],{"class":285}," typecheck\n",[80,857,858,860],{"class":82,"line":270},[80,859,831],{"class":207},[80,861,862],{"class":285}," build\n",[80,864,865,867],{"class":82,"line":304},[80,866,831],{"class":207},[80,868,869],{"class":285}," check:budget\n",[13,871,872],{},"这些命令分别检查 Frontmatter、taxonomy、占位语、站内链接、回归逻辑、TypeScript、Nuxt 预渲染和页面资源预算。职责被拆开后，编辑器不需要知道所有发布细节；规则变化时，只需要让导出格式继续满足 schema，并让仓库门禁给出最终结论。",[13,874,875,876,884,885,888],{},"Git 暂存区是人工审查的最后一道边界。",[54,877,880,883],{"href":878,"rel":879},"https:\u002F\u002Fgit-scm.com\u002Fdocs\u002Fgit-add",[58],[23,881,882],{},"git add"," 官方文档","明确说明该命令使用执行当时的工作树内容更新 index，后续修改不会自动进入提交。因此发布前需要查看 ",[23,886,887],{},"git diff","、明确暂存目标文件，再确认提交里没有混入浏览器缓存、构建目录或私有资源。",[28,890,891],{"id":891},"用这篇文章验证完整工作流",[13,893,894],{},"本文不是为界面准备的短样例，而是本次功能的验收载荷。它包含长段落、表格、外链、YAML、TypeScript、Bash 和纯文本代码块，可以同时验证 CodePaper 写作能力与 EchoBlog 内容渲染。",[13,896,897],{},"实际流程如下：",[899,900,901,913,920,923,936,939],"ol",{},[902,903,904,905,173,907,909,910,912],"li",{},"以 ",[23,906,111],{},[23,908,121],{}," 和 ",[23,911,115],{}," 文件名生成安全草稿。",[902,914,915,916,919],{},"将草稿放入 ",[23,917,918],{},"content\u002Farticles\u002F","，运行内容校验，确认字段和 taxonomy 可以被项目接受。",[902,921,922],{},"人工复核标题、摘要、slug、链接、代码片段、阅读时间与发布范围。",[902,924,925,926,928,929,932,933,143],{},"改为普通 ",[23,927,149],{}," 文件，显式设置 ",[23,930,931],{},"draft: false"," 与 ",[23,934,935],{},"indexable: true",[902,937,938],{},"重新运行完整门禁，确认文章进入列表、归档、标签页、sitemap 和生产预渲染。",[902,940,941],{},"只暂存文章与相关文档，创建独立内容提交。",[13,943,944],{},"这次验证比单元测试多覆盖了一层真实边界：不是只断言导出字符串里存在字段，而是让生成结果进入实际内容数据库、路由、链接扫描和生产构建。",[28,946,947],{"id":947},"仍然保留的人工步骤",[13,949,950],{},"自动补齐字段不能替代编辑判断。以下内容仍然需要作者确认：",[952,953,954,957,960,963,966,969],"ul",{},[902,955,956],{},"摘要是否准确，而不是仅仅截取第一段。",[902,958,959],{},"分类和标签是否表达文章主题，而不是为了增加曝光堆叠。",[902,961,962],{},"封面是否与文章相关，并且已经压缩到合适体积。",[902,964,965],{},"外部资料是否可信、可访问并真正支持正文结论。",[902,967,968],{},"代码片段是否与当前仓库实现一致。",[902,970,971],{},"文章是否值得索引，还是只应作为内部夹具保留。",[13,973,974],{},"因此当前实现不会直接向仓库写文件，也不会提供“导出并发布”按钮。只有在多作者、远程编辑或高频发布成为真实需求后，才有理由设计带身份、权限、版本与审计的服务端发布流程。",[28,976,977],{"id":977},"结论",[13,979,980],{},"内容工具最危险的捷径，是把“生成文件”和“允许发布”当成同一件事。CodePaper 现在能输出符合 EchoBlog schema 的完整文章草稿，但安全默认值、人工复核、Git 差异和构建门禁仍然保持独立。",[13,982,983],{},"这条链路没有消除人工操作，而是把人工判断集中在真正需要判断的地方：内容质量、公开范围和发布意图。格式补齐、白名单归一、文件命名和结构校验交给代码；是否让一篇文章进入生产环境，继续由可审查的 Git 工作流决定。",[985,986,987],"style",{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":45,"searchDepth":248,"depth":248,"links":989},[990,991,992,993,994,995,996,997,998],{"id":30,"depth":98,"text":30},{"id":67,"depth":98,"text":67},{"id":166,"depth":98,"text":166},{"id":397,"depth":98,"text":398},{"id":742,"depth":98,"text":743},{"id":815,"depth":98,"text":815},{"id":891,"depth":98,"text":891},{"id":947,"depth":98,"text":947},{"id":977,"depth":98,"text":977},"\u002Farticles\u002Fcodepaper-safe-draft-handoff","前端开发","\u002Fimages\u002Foptimized\u002Fproject-codepaper.jpg","2026-07-28","复盘 CodePaper 如何把浏览器里的自由写作转换为符合 EchoBlog schema、默认不可索引且可经过 Git 审查的安全文章草稿。",false,"md",{},"7 分钟",{"title":6,"description":1003},"用真实文章验证 CodePaper 的安全博客草稿导出，说明完整 Frontmatter、taxonomy 白名单、不可索引默认值和 Git 发布门禁如何共同降低误发布风险。","从 CodePaper 到 Git：安全博客草稿与内容发布门禁","articles\u002Fcodepaper-safe-draft-handoff",[1013,1014,1015,1016],"Markdown","Nuxt","TypeScript","内容系统","Ejh7OGbUQbhHbSnLxU7IU_udYDzOwfhyT-zeAMKGMYo",{"id":1019,"title":1020,"accent":7,"author":8,"body":1021,"canonical":1577,"category":1000,"cover":1001,"date":1002,"description":1578,"draft":1004,"extension":1005,"indexable":251,"meta":1579,"navigation":251,"path":1577,"readingTime":1580,"seo":1581,"seoDescription":1582,"seoTitle":1583,"stem":1584,"tags":1585,"updated":1002,"__hash__":1587},"articles\u002Farticles\u002Fcodepaper-safe-markdown-preview.md","在浏览器里安全预览 Markdown：CodePaper 的渲染边界",{"type":10,"value":1022,"toc":1568},[1023,1026,1029,1032,1044,1106,1109,1151,1166,1173,1176,1182,1185,1192,1209,1212,1215,1221,1229,1232,1286,1289,1292,1295,1298,1453,1460,1463,1487,1490,1504,1507,1510,1518,1524,1527,1549,1556,1559,1562,1565],[13,1024,1025],{},"技术写作工具最容易被低估的部分，不是文本框，也不是把 Markdown 变成 HTML，而是确定每一步能信任什么。只要预览区允许原始 HTML、代码高亮器会改写 DOM，或者导入的文件来自外部，渲染链路就已经跨过了普通文本展示的边界。",[13,1027,1028],{},"CodePaper 是 EchoBlog 内的本地优先写作原型。它没有账户、云同步和执行代码的沙箱，目标只是把编辑、预览、目录、草稿恢复和 Markdown 导出串成一条足够可靠的最小工作流。本文用这篇真实长文回头验证实现，并记录安全边界与仍然存在的发布摩擦。",[28,1030,1031],{"id":1031},"解析器不负责替你消毒",[13,1033,1034,1039,1040,1043],{},[54,1035,1038],{"href":1036,"rel":1037},"https:\u002F\u002Fmarked.js.org\u002F",[58],"Marked 官方文档","明确说明，它是 Markdown 编译器，不会消毒输出 HTML。下面这段输入对解析器来说是合法内容，但直接交给 ",[23,1041,1042],{},"v-html"," 就会把风险带到 DOM sink：",[38,1045,1050],{"className":1046,"code":1047,"filename":1048,"language":1049,"meta":45,"style":45},"language-html shiki shiki-themes github-light github-dark","\u003Cimg src=\"x\" onerror=\"alert('xss')\">\n\u003Ca href=\"javascript:alert('xss')\">危险链接\u003C\u002Fa>\n","malicious-input","html",[23,1051,1052,1085],{"__ignoreMap":45},[80,1053,1054,1057,1060,1063,1065,1068,1071,1073,1076,1079,1082],{"class":82,"line":83},[80,1055,1056],{"class":90},"\u003C",[80,1058,1059],{"class":86},"img",[80,1061,1062],{"class":207}," src",[80,1064,552],{"class":90},[80,1066,1067],{"class":285},"\"x\"",[80,1069,1070],{"class":207}," onerror",[80,1072,552],{"class":90},[80,1074,1075],{"class":285},"\"",[80,1077,1078],{"class":207},"alert",[80,1080,1081],{"class":285},"('xss')\"",[80,1083,1084],{"class":90},">\n",[80,1086,1087,1089,1091,1094,1096,1099,1102,1104],{"class":82,"line":98},[80,1088,1056],{"class":90},[80,1090,54],{"class":86},[80,1092,1093],{"class":207}," href",[80,1095,552],{"class":90},[80,1097,1098],{"class":285},"\"javascript:alert('xss')\"",[80,1100,1101],{"class":90},">危险链接\u003C\u002F",[80,1103,54],{"class":86},[80,1105,1084],{"class":90},[13,1107,1108],{},"因此，解析和安全处理必须是两个独立阶段：",[38,1110,1113],{"className":187,"code":1111,"filename":1112,"language":190,"meta":45,"style":45},"const parsedHtml = marked.parse(markdown)\nconst sanitizedHtml = DOMPurify.sanitize(parsedHtml, sanitizeOptions)\n","render-pipeline",[23,1114,1115,1133],{"__ignoreMap":45},[80,1116,1117,1119,1122,1124,1127,1130],{"class":82,"line":83},[80,1118,198],{"class":197},[80,1120,1121],{"class":94}," parsedHtml",[80,1123,204],{"class":197},[80,1125,1126],{"class":90}," marked.",[80,1128,1129],{"class":207},"parse",[80,1131,1132],{"class":90},"(markdown)\n",[80,1134,1135,1137,1140,1142,1145,1148],{"class":82,"line":98},[80,1136,198],{"class":197},[80,1138,1139],{"class":94}," sanitizedHtml",[80,1141,204],{"class":197},[80,1143,1144],{"class":90}," DOMPurify.",[80,1146,1147],{"class":207},"sanitize",[80,1149,1150],{"class":90},"(parsedHtml, sanitizeOptions)\n",[13,1152,1153,1154,1159,1160,173,1163,1165],{},"这里使用 DOMPurify，而不是自己维护标签正则。",[54,1155,1158],{"href":1156,"rel":1157},"https:\u002F\u002Fcheatsheetseries.owasp.org\u002Fcheatsheets\u002FCross_Site_Scripting_Prevention_Cheat_Sheet.html#html-sanitization",[58],"OWASP 的 XSS Prevention Cheat Sheet","也把 DOMPurify 作为 HTML 消毒方案推荐，并强调框架的默认转义不能覆盖 ",[23,1161,1162],{},"innerHTML",[23,1164,1042],{}," 一类显式逃生口。",[13,1167,1168,1169,1172],{},"CodePaper 只启用 HTML profile，并额外禁止脚本、样式、嵌入式内容、表单控件、内联样式和自定义 ",[23,1170,1171],{},"data-*"," 属性。这个允许范围不是通用富文本策略，而是根据当前 Markdown 预览需求收窄后的项目策略。",[28,1174,1175],{"id":1175},"为什么高亮之后还要再消毒",[13,1177,1178,1179,1181],{},"只在 Marked 之后消毒一次仍然不够。代码高亮器需要把纯文本拆成 token，再插入带 class 的 ",[23,1180,80],{},"。DOMPurify 和 OWASP 都提醒：消毒后的内容如果又被第三方库修改，原来的安全保证可能失效。",[13,1183,1184],{},"CodePaper 因此采用双重消毒：",[38,1186,1190],{"className":1187,"code":1188,"filename":1189,"language":44,"meta":45},[41],"Markdown\n  -> Marked 解析\n  -> 第一次 DOMPurify\n  -> 读取 code.textContent\n  -> 语言白名单归一\n  -> 生成高亮 token\n  -> 第二次 DOMPurify\n  -> 添加受控标题 ID 与展示属性\n  -> v-html\n","pipeline",[23,1191,1188],{"__ignoreMap":45},[13,1193,1194,1195,1198,1199,173,1202,1204,1205,1208],{},"高亮器拿到的是 ",[23,1196,1197],{},"code.textContent","，而不是代码块内部的 HTML。语言名称也不会直接变成任意 class：",[23,1200,1201],{},"typescript",[23,1203,190],{}," 等别名先映射到受支持语言，未知值降级为 ",[23,1206,1207],{},"plain","。这样既限制了高亮器输入，也避免把用户提供的 fence 信息原样扩散到 DOM。",[13,1210,1211],{},"最终一次消毒完成后，代码只补充程序自身生成的标题 ID、代码语言标签、外链安全属性与图片懒加载属性，不再拼接用户 HTML。这个顺序比“先消毒，之后随意增强 DOM”更容易审查。",[28,1213,1214],{"id":1214},"在低带宽站点里选择轻量高亮",[13,1216,1217,1218,143],{},"Shiki 的 TextMate grammar 和主题能力适合追求编辑器级一致性的场景，但 CodePaper 当前只是嵌入博客的浏览器原型。为了不让一个实验工具显著扩大页面 JavaScript，项目选择了 ",[23,1219,1220],{},"@speed-highlight\u002Fcore",[13,1222,1223,1228],{},[54,1224,1227],{"href":1225,"rel":1226},"https:\u002F\u002Fgithub.com\u002Fspeed-highlight\u002Fcore",[58],"Speed Highlight 的项目说明","把核心体积描述为约 2 KB，每种语言约增加 1 KB，并提供 TypeScript、Markdown、HTML、CSS、JSON、Bash 等常用语言支持。CodePaper 只把白名单归一后的语言交给高亮器，并把未知语言保留为可读纯文本。",[13,1230,1231],{},"这是一项明确的产品取舍：",[403,1233,1234,1244],{},[406,1235,1236],{},[409,1237,1238,1241],{},[412,1239,1240],{},"目标",[412,1242,1243],{},"当前选择",[422,1245,1246,1254,1262,1270,1278],{},[409,1247,1248,1251],{},[427,1249,1250],{},"快速打开",[427,1252,1253],{},"按需加载 CodePaper 客户端组件",[409,1255,1256,1259],{},[427,1257,1258],{},"代码可读",[427,1260,1261],{},"常用语言轻量词法高亮",[409,1263,1264,1267],{},[427,1265,1266],{},"不破坏正文",[427,1268,1269],{},"未知语言显示纯文本",[409,1271,1272,1275],{},[427,1273,1274],{},"控制资源",[427,1276,1277],{},"构建预算检查页面脚本体积",[409,1279,1280,1283],{},[427,1281,1282],{},"编辑器级语义",[427,1284,1285],{},"不属于当前原型范围",[13,1287,1288],{},"如果未来需要 TextMate 级语义、更多主题或服务端一致高亮，应该重新评估 Shiki，而不是继续给轻量高亮器叠加复杂规则。",[28,1290,1291],{"id":1291},"目录必须来自最终可见结构",[13,1293,1294],{},"长文预览除了代码块，还需要让作者快速检查文章层级。CodePaper 从最终 DOM 中读取二、三级标题，生成目录并分配稳定锚点。",[13,1296,1297],{},"重复标题需要确定性去重：",[38,1299,1302],{"className":187,"code":1300,"filename":1301,"language":190,"meta":45,"style":45},"function createHeadingId(text: string, occurrences: Map\u003Cstring, number>) {\n  const base = slugify(text) || 'section'\n  const count = (occurrences.get(base) ?? 0) + 1\n  occurrences.set(base, count)\n\n  return count === 1 ? base : `${base}-${count}`\n}\n","heading-id",[23,1303,1304,1345,1367,1398,1409,1413,1449],{"__ignoreMap":45},[80,1305,1306,1309,1312,1314,1316,1319,1322,1324,1327,1329,1332,1334,1337,1339,1342],{"class":82,"line":83},[80,1307,1308],{"class":197},"function",[80,1310,1311],{"class":207}," createHeadingId",[80,1313,282],{"class":90},[80,1315,44],{"class":232},[80,1317,1318],{"class":197},":",[80,1320,1321],{"class":94}," string",[80,1323,335],{"class":90},[80,1325,1326],{"class":232},"occurrences",[80,1328,1318],{"class":197},[80,1330,1331],{"class":207}," Map",[80,1333,1056],{"class":90},[80,1335,1336],{"class":94},"string",[80,1338,335],{"class":90},[80,1340,1341],{"class":94},"number",[80,1343,1344],{"class":90},">) {\n",[80,1346,1347,1350,1353,1355,1358,1361,1364],{"class":82,"line":98},[80,1348,1349],{"class":197},"  const",[80,1351,1352],{"class":94}," base",[80,1354,204],{"class":197},[80,1356,1357],{"class":207}," slugify",[80,1359,1360],{"class":90},"(text) ",[80,1362,1363],{"class":197},"||",[80,1365,1366],{"class":285}," 'section'\n",[80,1368,1369,1371,1374,1376,1379,1382,1385,1388,1391,1393,1395],{"class":82,"line":248},[80,1370,1349],{"class":197},[80,1372,1373],{"class":94}," count",[80,1375,204],{"class":197},[80,1377,1378],{"class":90}," (occurrences.",[80,1380,1381],{"class":207},"get",[80,1383,1384],{"class":90},"(base) ",[80,1386,1387],{"class":197},"??",[80,1389,1390],{"class":94}," 0",[80,1392,236],{"class":90},[80,1394,649],{"class":197},[80,1396,1397],{"class":94}," 1\n",[80,1399,1400,1403,1406],{"class":82,"line":255},[80,1401,1402],{"class":90},"  occurrences.",[80,1404,1405],{"class":207},"set",[80,1407,1408],{"class":90},"(base, count)\n",[80,1410,1411],{"class":82,"line":270},[80,1412,252],{"emptyLinePlaceholder":251},[80,1414,1415,1418,1421,1423,1426,1429,1432,1434,1437,1440,1443,1446],{"class":82,"line":304},[80,1416,1417],{"class":197},"  return",[80,1419,1420],{"class":90}," count ",[80,1422,701],{"class":197},[80,1424,1425],{"class":94}," 1",[80,1427,1428],{"class":197}," ?",[80,1430,1431],{"class":90}," base ",[80,1433,1318],{"class":197},[80,1435,1436],{"class":285}," `${",[80,1438,1439],{"class":90},"base",[80,1441,1442],{"class":285},"}-${",[80,1444,1445],{"class":90},"count",[80,1447,1448],{"class":285},"}`\n",[80,1450,1451],{"class":82,"line":310},[80,1452,307],{"class":90},[13,1454,1455,1456,1459],{},"目录使用语义化 ",[23,1457,1458],{},"nav","，点击时定位到标题；滚动时再根据标题位置更新当前章节。锚点只来自文本和受控计数器，不接受 Markdown 中的任意 ID。这样目录、链接和预览区引用的是同一份最终结构。",[28,1461,1462],{"id":1462},"本地优先不等于永不丢失",[13,1464,1465,1466,1469,1470,1473,1474,1477,1478,1483,1484,143],{},"CodePaper 把草稿写进 ",[23,1467,1468],{},"localStorage","，500ms 防抖自动保存，并支持 ",[23,1471,1472],{},"Ctrl+S"," 或 ",[23,1475,1476],{},"Command+S"," 立即保存。",[54,1479,1482],{"href":1480,"rel":1481},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FlocalStorage",[58],"MDN 的 localStorage 文档","说明，数据按 origin 隔离并可以跨浏览器会话保留；同时，浏览器策略可能拒绝持久化并抛出 ",[23,1485,1486],{},"SecurityError",[13,1488,1489],{},"因此本地保存必须是可失败的能力，而不能是唯一出口：",[899,1491,1492,1495,1498,1501],{},[902,1493,1494],{},"写入失败时显示明确状态。",[902,1496,1497],{},"导入文件限制为 256 KB，正文还需通过长度校验。",[902,1499,1500],{},"用户随时可以复制正文或导出 Markdown。",[902,1502,1503],{},"不承诺跨设备恢复、版本历史或云端备份。",[13,1505,1506],{},"这也解释了为什么当前导出仍然重要：它不是装饰性的“下载”按钮，而是浏览器存储不可用时的逃生路径。",[28,1508,1509],{"id":1509},"真实文章暴露的发布缺口",[13,1511,1512,1513,173,1515,1517],{},"这篇文章实际覆盖了 CodePaper 需要处理的主要结构：多个二级标题、重复的技术术语、表格、列表、外链、",[23,1514,1049],{},[23,1516,190],{}," 与纯文本代码块。导入后，高亮、语言标签、目录和手机预览都能正常工作。",[13,1519,1520,1521,1523],{},"这次真实使用当时暴露出一个比片段库更靠前的问题：CodePaper 导出的是便携 Markdown，只包含标题、标签和正文；EchoBlog 正式文章还要求日期、作者、分类、封面、阅读时间、索引状态与 SEO 字段。直接把导出文件放进 ",[23,1522,918],{}," 不能通过发布门禁。",[13,1525,1526],{},"这个结果把后续优先级调整为可审查的“导出为 EchoBlog 草稿”流程：",[952,1528,1529,1532,1539,1542],{},[902,1530,1531],{},"显式选择已登记的分类和标签。",[902,1533,1534,1535,932,1537,143],{},"默认输出 ",[23,1536,111],{},[23,1538,121],{},[902,1540,1541],{},"生成符合项目 schema 的完整 Frontmatter。",[902,1543,1544,1545,1548],{},"导出后仍由 ",[23,1546,1547],{},"pnpm validate:content"," 和链接检查负责最终发布判断。",[13,1550,1551,1552,1555],{},"这条交接流程现已实现，并用",[54,1553,1554],{"href":999},"《从浏览器草稿到 Git 发布：CodePaper 的安全内容交接》","完成自举验证：安全草稿不会进入生产构建，只有经过人工复核并显式解除草稿与不可索引状态后才成为正式内容。它缩短了真实写作到仓库内容之间的距离，同时保留 Git 审查和现有发布门禁，不需要把 CodePaper 变成 CMS。",[28,1557,1558],{"id":1558},"当前边界",[13,1560,1561],{},"CodePaper 的预览经过消毒，但它不是不可信代码执行环境。代码块永远作为文本展示，不会运行 JavaScript、HTML 或用户脚本；导出的 Markdown 也必须再次经过目标发布系统的校验。",[13,1563,1564],{},"当前实现已经验证了本地长文编辑、安全预览、轻量高亮、目录和博客草稿交接闭环。下一阶段继续观察重复代码是否真的成为主要阻塞；只有出现明确需求时，才增加片段库、全文搜索、历史版本或独立部署。",[985,1566,1567],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":45,"searchDepth":248,"depth":248,"links":1569},[1570,1571,1572,1573,1574,1575,1576],{"id":1031,"depth":98,"text":1031},{"id":1175,"depth":98,"text":1175},{"id":1214,"depth":98,"text":1214},{"id":1291,"depth":98,"text":1291},{"id":1462,"depth":98,"text":1462},{"id":1509,"depth":98,"text":1509},{"id":1558,"depth":98,"text":1558},"\u002Farticles\u002Fcodepaper-safe-markdown-preview","复盘 CodePaper 如何组合 Marked、DOMPurify 和轻量代码高亮，在本地优先的写作原型中处理 XSS、目录锚点、草稿恢复与低带宽约束。",{},"11 分钟",{"title":1020,"description":1578},"复盘 CodePaper 如何在浏览器端组合 Marked、DOMPurify、Speed Highlight 和 localStorage，构建可验证的 Markdown 安全预览与本地草稿工作流。","浏览器 Markdown 安全预览实践：DOMPurify、代码高亮与目录","articles\u002Fcodepaper-safe-markdown-preview",[1013,1015,1586],"代码高亮","6dKx1kaV44bfkRDYokXjZon007Tr3rTP84AititgePs",{"id":1589,"title":1590,"accent":1591,"author":8,"body":1592,"canonical":1649,"category":1650,"cover":1651,"date":1652,"description":1653,"draft":1004,"extension":1005,"indexable":251,"meta":1654,"navigation":251,"path":1655,"readingTime":1656,"seo":1657,"seoDescription":1649,"seoTitle":1649,"stem":1658,"tags":1659,"updated":1649,"__hash__":1662},"articles\u002Farticles\u002Fdesk-redesign-2.md","我的桌面改造计划 2.0","#37c8c6",{"type":10,"value":1593,"toc":1642},[1594,1597,1600,1603,1606,1609,1612,1615,1618,1621,1624,1627,1630,1633,1636,1639],[13,1595,1596],{},"桌面不是展示柜，而是每天真实工作的地方。改造的目标不是塞满设备，而是让常用物品伸手可及，让不常用物品安静退场。",[28,1598,1599],{"id":1599},"先定义使用场景",[13,1601,1602],{},"我先把桌面使用场景分成三类：写代码、写东西、整理素材。写代码需要稳定的显示器角度和输入设备；写东西需要留出纸笔空间；整理素材则需要临时摆放相机、读卡器或移动硬盘。",[13,1604,1605],{},"如果不先定义场景，改造很容易变成买配件。配件看起来解决了问题，实际只是把旧问题换了一个更贵的位置。",[28,1607,1608],{"id":1608},"先处理线材",[13,1610,1611],{},"线材会直接影响视觉清爽程度，也会影响维护成本。我把电源、扩展坞和充电线固定在桌面背面，只留下需要频繁插拔的接口。",[13,1613,1614],{},"线材管理有两个目标：第一是看不见，第二是拿得出。完全封死的理线方案短期很好看，但换设备时会很痛苦。我会给常用接口留一点冗余长度，把电源线和数据线分开固定，这样排查问题时不会牵一发动全身。",[28,1616,1617],{"id":1617},"再处理光线",[13,1619,1620],{},"白天靠自然光，晚上用低色温氛围灯和中性色温工作灯。这样写作、阅读和调试代码时的状态会更稳定。",[13,1622,1623],{},"我尽量避免让灯直接照到屏幕，也不把氛围灯放得太亮。桌面的光线应该服务于眼睛，而不是服务于照片。实际使用时，屏幕亮度、键盘背光和桌面灯光要一起调整，单独拉高其中一个都会让眼睛更累。",[28,1625,1626],{"id":1626},"分层收纳",[13,1628,1629],{},"收纳不是把东西藏起来，而是让每类物品有固定回路。每天会用的东西留在手边，每周用几次的放抽屉，偶尔用的放到盒子里。桌面上少一个“临时放一下”的区域，杂乱就会少很多。",[13,1631,1632],{},"我给自己留了一个小托盘，专门放当天会处理的零碎东西。托盘满了就必须整理，避免杂物从一个角落扩散到整张桌子。",[28,1634,1635],{"id":1635},"最后留白",[13,1637,1638],{},"桌面上永远留一块空白区域。它可以放书、草稿纸、相机，也可以什么都不放。空白本身就是一种功能。",[13,1640,1641],{},"改造结束后，我不会马上继续购买新东西，而是至少使用两周。真正的问题会在重复使用中浮出来：显示器是否太高，键盘是否太远，充电线是否总是不够长。比起一次到位，桌面更适合慢慢迭代。",{"title":45,"searchDepth":248,"depth":248,"links":1643},[1644,1645,1646,1647,1648],{"id":1599,"depth":98,"text":1599},{"id":1608,"depth":98,"text":1608},{"id":1617,"depth":98,"text":1617},{"id":1626,"depth":98,"text":1626},{"id":1635,"depth":98,"text":1635},null,"生活","\u002Fimages\u002Foptimized\u002Fpost-desk-redesign.jpg","2026-06-12","设备、收纳与氛围，一个更适合专注与创作的角落。",{},"\u002Farticles\u002Fdesk-redesign-2","8 分钟",{"title":1590,"description":1653},"articles\u002Fdesk-redesign-2",[1660,1650,1661],"桌面","灵感","c27k7LkKH2KQDBr9riJmdhUmfGGARFFz7H86whZ60QQ",{"id":1664,"title":1665,"accent":1666,"author":8,"body":1667,"canonical":1912,"category":1000,"cover":1913,"date":1914,"description":1915,"draft":1004,"extension":1005,"indexable":251,"meta":1916,"navigation":251,"path":1912,"readingTime":1917,"seo":1918,"seoDescription":1919,"seoTitle":1920,"stem":1921,"tags":1922,"updated":1914,"__hash__":1923},"articles\u002Farticles\u002Fechoblog-content-pipeline.md","从原型到上线：EchoBlog 的内容与发布工程","#ff6f9f",{"type":10,"value":1668,"toc":1902},[1669,1672,1675,1679,1688,1691,1712,1715,1728,1731,1734,1744,1747,1758,1761,1768,1771,1817,1820,1824,1827,1830,1838,1841,1844,1847,1850,1854,1857,1863,1866,1870,1873,1876,1879,1882,1896,1899],[13,1670,1671],{},"一个个人博客很容易从“做一个好看的首页”开始，也很容易停在这里。真正决定它能不能长期维护的，不是首屏用了多少动画，而是新增内容、发现内容、发布版本和处理故障时，是否都有清晰且可重复的路径。",[13,1673,1674],{},"EchoBlog 的目标因此逐渐从视觉原型变成一套完整工作流：内容使用 Markdown 管理，页面由 Nuxt 服务端渲染，搜索可以定位到正文标题，资源有 CDN 与同源回退，发布失败能够自动回滚，搜索引擎也能获得稳定的 canonical、sitemap 和结构化数据。",[28,1676,1678],{"id":1677},"先定义内容事实而不是先堆页面","先定义内容事实，而不是先堆页面",[13,1680,1681,1682,932,1684,1687],{},"文章和项目分别位于 ",[23,1683,918],{},[23,1685,1686],{},"content\u002Fprojects\u002F","。页面不再维护另一份静态列表，而是从 Nuxt Content 查询结构化 Frontmatter。这样，标题、标签、更新时间、封面和详情页始终来自同一份内容。",[13,1689,1690],{},"内容层还增加了三道约束：",[899,1692,1693,1699,1707],{},[902,1694,1695,1696,1698],{},"标签和分类必须在 ",[23,1697,183],{}," 中登记，避免同义标签不断分裂。",[902,1700,1701,1702,909,1704,1706],{},"草稿同时使用 ",[23,1703,111],{},[23,1705,115],{}," 文件名，生产构建会排除草稿文件。",[902,1708,1709,1711],{},[23,1710,1547],{}," 在构建前检查 Frontmatter、分类、标签与发布占位语。",[13,1713,1714],{},"新内容可以从交互式命令开始：",[38,1716,1719],{"className":821,"code":1717,"filename":1718,"language":824,"meta":45,"style":45},"pnpm create:content\n","terminal",[23,1720,1721],{"__ignoreMap":45},[80,1722,1723,1725],{"class":82,"line":83},[80,1724,831],{"class":207},[80,1726,1727],{"class":285}," create:content\n",[13,1729,1730],{},"脚本默认创建草稿，生成稳定的 ASCII slug，并拒绝覆盖已有文件。作者完成正文和发布信息后，再显式切换为正式内容。这比直接复制旧 Markdown 更不容易留下错误字段。",[28,1732,1733],{"id":1733},"一份索引服务多个入口",[13,1735,1736,1737,932,1740,1743],{},"首页、文章页、项目页、标签页、归档页和近期足迹需要读取同一批内容，但它们的排序和筛选方式不同。项目将查询封装进 ",[23,1738,1739],{},"useArticleIndex",[23,1741,1742],{},"useProjectIndex","，再由标签、归档和近期活动组合逻辑消费。",[13,1745,1746],{},"标签筛选采用交集语义。选择多个标签后，内容必须同时满足所有选中标签；没有结果时展示明确空状态，而不是留下空白区域。标签数量也从当前内容实时计算，不再使用样例数字。",[13,1748,1749,1750,1753,1754,1757],{},"近期足迹同样由文章 ",[23,1751,1752],{},"updated\u002Fdate"," 与项目 ",[23,1755,1756],{},"updated\u002Fyear"," 派生，避免首页维护一份很快过期的手写时间线。",[28,1759,1760],{"id":1760},"搜索应当把人带到具体答案",[13,1762,1763,1764,1767],{},"只搜索标题对技术博客不够。EchoBlog 在服务端通过 Nuxt Content 的搜索段落数据构建索引，再使用 MiniSearch 完成相关度排序。中文文本由 ",[23,1765,1766],{},"Intl.Segmenter"," 分词，并补充单字和双字片段，兼顾短关键词与连续词组。",[13,1769,1770],{},"索引字段包含：",[403,1772,1773,1783],{},[406,1774,1775],{},[409,1776,1777,1780],{},[412,1778,1779],{},"字段",[412,1781,1782],{},"用途",[422,1784,1785,1793,1801,1809],{},[409,1786,1787,1790],{},[427,1788,1789],{},"标题",[427,1791,1792],{},"最高权重，快速命中文章或项目主题",[409,1794,1795,1798],{},[427,1796,1797],{},"章节标题",[427,1799,1800],{},"把结果定位到具体小节",[409,1802,1803,1806],{},[427,1804,1805],{},"标签与分类",[427,1807,1808],{},"支持从概念词进入内容",[409,1810,1811,1814],{},[427,1812,1813],{},"正文",[427,1815,1816],{},"覆盖真正的答案和实现细节",[13,1818,1819],{},"搜索结果保留章节锚点。用户按下回车后可以直接进入命中的小节，而不是只打开文章顶部。搜索弹层同时遵循组合框语义，支持方向键选择、Esc 关闭和焦点恢复。",[28,1821,1823],{"id":1822},"ssr-页面也需要稳定的首屏数据","SSR 页面也需要稳定的首屏数据",[13,1825,1826],{},"天气、访问统计和内容查询都可能在服务端与客户端之间切换状态。如果首屏 HTML 与水合时的客户端状态不同，就会出现闪烁或 hydration warning。",[13,1828,1829],{},"当前实现遵循两个原则：",[952,1831,1832,1835],{},[902,1833,1834],{},"首次渲染使用服务端可确定的状态，客户端挂载后才进入需要浏览器能力的状态。",[902,1836,1837],{},"外部接口失败时保留结构稳定的降级数据，不让整个页面因为一个卡片不可用而失败。",[13,1839,1840],{},"天气接口还按位置隔离缓存，访问统计用匿名哈希与会话窗口完成轻量 PV\u002FUV 去重。它们适合当前单机个人站；只有在多实例或分析需求出现时，才值得迁移到数据库或独立统计服务。",[28,1842,1843],{"id":1843},"资源策略从低带宽约束出发",[13,1845,1846],{},"图片源文件不会直接进入首屏。项目使用脚本生成限制尺寸与质量的 JPEG，音乐封面也有独立压缩流程。大体积音乐优先从静态域名加载，同源地址作为失败回退。",[13,1848,1849],{},"播放器没有在页面初始化时下载整份歌单。音频在用户播放时加载，封面和歌词按当前曲目切换。构建预算还会检查核心页面是否意外直接引用音频或 WASM，避免一次组件改动把首屏体积拉回去。",[28,1851,1853],{"id":1852},"发布不是一条-ssh-命令","发布不是一条 SSH 命令",[13,1855,1856],{},"部署流程把质量门禁放在上传之前：",[38,1858,1861],{"className":1859,"code":1860,"language":44,"meta":45},[41],"内容校验 -> 单元测试 -> 类型检查 -> 生产构建\n        -> 资源预算 -> 本地冒烟 -> SSH 发布\n        -> 服务重启 -> 健康检查 -> 失败回滚\n",[23,1862,1860],{"__ignoreMap":45},[13,1864,1865],{},"发布后，独立巡检任务会继续请求核心页面、动态内容路由、健康接口、sitemap、静态资源和 CDN 音频 Range。构建成功只能证明产物可以生成，生产巡检才能证明域名、反向代理、证书和资源服务仍然可用。",[28,1867,1869],{"id":1868},"seo-是内容模型的下游","SEO 是内容模型的下游",[13,1871,1872],{},"每个页面的标题、描述、canonical 和 Open Graph 都从统一站点配置与内容元数据生成。文章和项目详情还输出 JSON-LD，sitemap 则直接遍历当前内容集合。",[13,1874,1875],{},"这意味着发布一篇新文章时，不需要手工修改 sitemap，也不应该在页面组件中重复填写标题。SEO 的可靠性来自内容模型的一致，而不是发布后的人工补丁。",[28,1877,1878],{"id":1878},"下一步不是继续加功能",[13,1880,1881],{},"工程链路稳定后，最重要的事情变成持续写真实内容，并观察真实数据：",[952,1883,1884,1887,1890,1893],{},[902,1885,1886],{},"Search Console 是否抓取了预期页面，查询词是否与内容主题一致。",[902,1888,1889],{},"生产巡检是否出现间歇性接口、证书或 CDN 故障。",[902,1891,1892],{},"资源预算是否随着图片、音乐和组件增加而接近上限。",[902,1894,1895],{},"哪些项目真正有演示和源码，哪些只是概念验证，页面是否诚实表达了状态。",[13,1897,1898],{},"个人博客不需要一开始就拥有评论、复杂 CMS、多作者权限和完整分析后台。先把写作、发布、发现和故障恢复做成可靠的日常流程，站点才有持续生长的基础。",[985,1900,1901],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":45,"searchDepth":248,"depth":248,"links":1903},[1904,1905,1906,1907,1908,1909,1910,1911],{"id":1677,"depth":98,"text":1678},{"id":1733,"depth":98,"text":1733},{"id":1760,"depth":98,"text":1760},{"id":1822,"depth":98,"text":1823},{"id":1843,"depth":98,"text":1843},{"id":1852,"depth":98,"text":1853},{"id":1868,"depth":98,"text":1869},{"id":1878,"depth":98,"text":1878},"\u002Farticles\u002Fechoblog-content-pipeline","\u002Fimages\u002Foptimized\u002Fproject-echoblog.jpg","2026-07-17","复盘一个 Nuxt 个人博客如何把内容模型、全文搜索、SEO、资源策略和自动发布串成可维护的工作流。",{},"12 分钟",{"title":1665,"description":1915},"复盘 EchoBlog 如何使用 Nuxt Content、Nitro、全文搜索、SEO 和 GitHub Actions，建立可验证的个人博客内容发布流程。","EchoBlog 的 Nuxt 内容与发布工程实践","articles\u002Fechoblog-content-pipeline",[1014,1015,1016],"3DUMP29hq4Llmi1kRplw5Lb2cBDa0_ogOhugRak8eXA",{"id":1925,"title":1926,"accent":1927,"author":8,"body":1928,"canonical":1649,"category":1000,"cover":2113,"date":2114,"description":2115,"draft":1004,"extension":1005,"indexable":251,"meta":2116,"navigation":251,"path":2117,"readingTime":2118,"seo":2119,"seoDescription":1649,"seoTitle":1649,"stem":2120,"tags":2121,"updated":1649,"__hash__":2124},"articles\u002Farticles\u002Fgsap-page-transitions.md","用 GSAP 打造丝滑的页面过渡动画","#ff7aa8",{"type":10,"value":1929,"toc":2106},[1930,1933,1936,1939,1942,1946,1949,1956,2058,2065,2068,2071,2074,2077,2080,2083,2097,2100,2103],[13,1931,1932],{},"页面过渡不是为了炫技，而是为了让用户理解“我从哪里来，又到了哪里去”。在个人博客里，动效可以更轻一点：进入时有层次，离开时不打扰阅读，滚动时给一点反馈。",[28,1934,1935],{"id":1935},"基本思路",[13,1937,1938],{},"我会把动效拆成三类：首屏进入、列表 stagger、滚动进度。首屏进入负责建立情绪，列表 stagger 让信息更容易扫描，滚动进度则给长页面一个方向感。它们不应该互相争抢注意力，而是像页面自己的呼吸。",[13,1940,1941],{},"实际设计时，我先把页面拆成几个稳定层级：导航、主标题、正文卡片、侧边信息。动画只作用在层级切换上，不直接改变内容结构。这样后续即使调整布局，动效依然可以复用。",[28,1943,1945],{"id":1944},"在-nuxt-中落地","在 Nuxt 中落地",[13,1947,1948],{},"Nuxt 的页面结构天然适合做局部动效。页面只需要给关键模块加上稳定的选择器，然后在客户端用 GSAP 处理进入节奏。这样不会污染内容层，也不会把样式写成难以维护的状态机。",[13,1950,1951,1952,1955],{},"我的习惯是让组件只声明“这里可以被动画处理”，真正的时间线放在 composable 里。比如卡片统一使用 ",[23,1953,1954],{},"data-animate"," 标记，页面挂载后再收集这些节点。这样不同页面可以共用同一套 reveal 逻辑，也能统一处理销毁。",[38,1957,1959],{"className":187,"code":1958,"language":190,"meta":45,"style":45},"const scope = gsap.context(() => {\n  gsap.from('[data-animate]', {\n    y: 18,\n    opacity: 0,\n    duration: 0.64,\n    ease: 'power3.out',\n    stagger: 0.08\n  })\n}, root)\n",[23,1960,1961,1984,2000,2011,2020,2030,2040,2048,2053],{"__ignoreMap":45},[80,1962,1963,1965,1968,1970,1973,1976,1979,1981],{"class":82,"line":83},[80,1964,198],{"class":197},[80,1966,1967],{"class":94}," scope",[80,1969,204],{"class":197},[80,1971,1972],{"class":90}," gsap.",[80,1974,1975],{"class":207},"context",[80,1977,1978],{"class":90},"(() ",[80,1980,239],{"class":197},[80,1982,1983],{"class":90}," {\n",[80,1985,1986,1989,1992,1994,1997],{"class":82,"line":98},[80,1987,1988],{"class":90},"  gsap.",[80,1990,1991],{"class":207},"from",[80,1993,282],{"class":90},[80,1995,1996],{"class":285},"'[data-animate]'",[80,1998,1999],{"class":90},", {\n",[80,2001,2002,2005,2008],{"class":82,"line":248},[80,2003,2004],{"class":90},"    y: ",[80,2006,2007],{"class":94},"18",[80,2009,2010],{"class":90},",\n",[80,2012,2013,2016,2018],{"class":82,"line":255},[80,2014,2015],{"class":90},"    opacity: ",[80,2017,720],{"class":94},[80,2019,2010],{"class":90},[80,2021,2022,2025,2028],{"class":82,"line":270},[80,2023,2024],{"class":90},"    duration: ",[80,2026,2027],{"class":94},"0.64",[80,2029,2010],{"class":90},[80,2031,2032,2035,2038],{"class":82,"line":304},[80,2033,2034],{"class":90},"    ease: ",[80,2036,2037],{"class":285},"'power3.out'",[80,2039,2010],{"class":90},[80,2041,2042,2045],{"class":82,"line":310},[80,2043,2044],{"class":90},"    stagger: ",[80,2046,2047],{"class":94},"0.08\n",[80,2049,2050],{"class":82,"line":315},[80,2051,2052],{"class":90},"  })\n",[80,2054,2055],{"class":82,"line":351},[80,2056,2057],{"class":90},"}, root)\n",[13,2059,2060,2061,2064],{},"这段代码的重点不是参数，而是边界：动画只读取 ",[23,2062,2063],{},"root"," 内的节点，并在页面卸载时统一 revert。否则切换路由几次后，页面状态会变得很难追踪。",[28,2066,2067],{"id":2067},"页面切换的节奏",[13,2069,2070],{},"页面切换可以分成两个阶段：旧页面离开，新页面进入。个人博客不需要复杂的转场遮罩，最稳定的做法是让旧页面轻微淡出，新页面带一点位移进入。切换应该快，不能让用户觉得等待是动效的一部分。",[13,2072,2073],{},"如果页面内容来自异步数据，动画要等关键内容准备好再启动。否则用户会看到空卡片先动，文字后出现，这种错位会让动效显得廉价。",[28,2075,2076],{"id":2076},"控制克制",[13,2078,2079],{},"好的动效应该能被感觉到，但不应该抢走内容注意力。我的默认规则是：位移短、时间短、缓动柔和，并尊重系统的减少动态效果设置。",[13,2081,2082],{},"我会给自己设几条限制：",[952,2084,2085,2088,2091,2094],{},[902,2086,2087],{},"单个 reveal 动画尽量控制在 700ms 内。",[902,2089,2090],{},"列表 stagger 不超过 100ms，否则大列表会拖慢阅读。",[902,2092,2093],{},"不给正文段落逐段加动画，正文是阅读对象，不是舞台。",[902,2095,2096],{},"系统开启减少动态效果时，所有装饰动画都应该停止。",[28,2098,2099],{"id":2099},"最后",[13,2101,2102],{},"GSAP 很强，但页面动效真正难的不是 API，而是判断“哪里不该动”。个人博客的气质应该来自内容和排版，动效只是帮助页面更自然地出现。",[985,2104,2105],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":45,"searchDepth":248,"depth":248,"links":2107},[2108,2109,2110,2111,2112],{"id":1935,"depth":98,"text":1935},{"id":1944,"depth":98,"text":1945},{"id":2067,"depth":98,"text":2067},{"id":2076,"depth":98,"text":2076},{"id":2099,"depth":98,"text":2099},"\u002Fimages\u002Foptimized\u002Fpost-gsap-transition.jpg","2026-06-18","记录一次在 Nuxt 中实现页面切换动画的实践与思考。",{},"\u002Farticles\u002Fgsap-page-transitions","10 分钟",{"title":1926,"description":2115},"articles\u002Fgsap-page-transitions",[1014,2122,2123],"GSAP","动画","z7n2g0l4zLK61tZe_GeLEuWO2FUxuPUySv_xgC70Puc",{"id":2126,"title":2127,"accent":2128,"author":8,"body":2129,"canonical":1649,"category":2200,"cover":2201,"date":2202,"description":2203,"draft":1004,"extension":1005,"indexable":251,"meta":2204,"navigation":251,"path":2205,"readingTime":1656,"seo":2206,"seoDescription":1649,"seoTitle":1649,"stem":2207,"tags":2208,"updated":1649,"__hash__":2211},"articles\u002Farticles\u002Findie-builder-notes.md","关于独立开发的一些小想法","#9b7cff",{"type":10,"value":2130,"toc":2193},[2131,2134,2137,2140,2143,2146,2149,2152,2155,2158,2161,2172,2175,2178,2181,2184,2187,2190],[13,2132,2133],{},"独立开发最难的部分并不是写代码，而是决定什么不做。一个人能投入的注意力有限，项目越小，越需要清楚地知道核心体验是什么。",[28,2135,2136],{"id":2136},"从一个具体问题开始",[13,2138,2139],{},"我现在更倾向于从一个很具体的问题开始，而不是从一个宏大的产品想法开始。比如“我想更快记录灵感”比“我要做一个知识管理平台”更容易推进。前者能很快做出可用版本，后者会不断膨胀。",[13,2141,2142],{},"独立项目的早期目标不是证明自己能做很多功能，而是证明这个问题值得继续做。",[28,2144,2145],{"id":2145},"保持节奏",[13,2147,2148],{},"我会把项目拆成可以在一个晚上完成的小块。每个小块都要能带来一点可见变化，这样长期推进时不容易失去方向。",[13,2150,2151],{},"一个好的小块应该能被明确验收：新增一个页面、完成一个筛选、修掉一个交互卡点。太抽象的任务，比如“优化体验”，很容易拖成无底洞。真正需要优化时，也要先把问题写清楚：是加载慢、信息不清楚，还是路径太长。",[28,2153,2154],{"id":2154},"记录取舍",[13,2156,2157],{},"每次砍掉功能都写下原因。未来如果想重新加回来，也能知道当时的上下文，而不是在同一个问题上反复摇摆。",[13,2159,2160],{},"我会记录三类内容：",[952,2162,2163,2166,2169],{},[902,2164,2165],{},"为什么要做这个功能。",[902,2167,2168],{},"为什么现在不做某个功能。",[902,2170,2171],{},"哪些地方是临时方案。",[13,2173,2174],{},"这些记录能减少很多重复思考。一个人做项目时，没有团队会议帮你同步上下文，文档就是自己的第二记忆。",[28,2176,2177],{"id":2177},"允许粗糙",[13,2179,2180],{},"早期版本可以粗糙，但不能含糊。粗糙是细节不够，含糊是目标不清。两者的修复成本完全不同。",[13,2182,2183],{},"粗糙的按钮、临时的文案、还没接真实 API 的数据，都可以后面补。但如果核心流程本身说不清楚，越往后做越痛苦。我的经验是：能在纸上画清楚的流程，代码里也更容易落地。",[28,2185,2186],{"id":2186},"不要过早追求完整",[13,2188,2189],{},"完整是独立开发里很危险的词。评论、订阅、国际化、主题市场、后台管理，每个都看起来合理，但它们会迅速吃掉注意力。对个人项目来说，先做出可用的第一版，比做出完整但永远不上线的版本更重要。",[13,2191,2192],{},"真正值得做的项目，会在使用中自然暴露下一步。只要保持可维护，慢一点没关系。",{"title":45,"searchDepth":248,"depth":248,"links":2194},[2195,2196,2197,2198,2199],{"id":2136,"depth":98,"text":2136},{"id":2145,"depth":98,"text":2145},{"id":2154,"depth":98,"text":2154},{"id":2177,"depth":98,"text":2177},{"id":2186,"depth":98,"text":2186},"随笔","\u002Fimages\u002Foptimized\u002Fpost-indie-road.jpg","2026-06-06","在独立开发的路上，如何保持热情与节奏。",{},"\u002Farticles\u002Findie-builder-notes",{"title":2127,"description":2203},"articles\u002Findie-builder-notes",[2209,2200,2210],"独立开发","成长","y0_A5xhFvyOHZjS7xndGzsLefrR2EUHDvvePqXVDz0o",{"id":2213,"title":2214,"accent":7,"author":8,"body":2215,"canonical":2877,"category":1000,"cover":1001,"date":2878,"description":2879,"draft":1004,"extension":1005,"indexable":1004,"meta":2880,"navigation":251,"path":2877,"readingTime":2881,"seo":2882,"seoDescription":2883,"seoTitle":2884,"stem":2885,"tags":2886,"updated":2887,"__hash__":2888},"articles\u002Farticles\u002Fmarkdown-code-fixture.md","Markdown 与代码块渲染测试",{"type":10,"value":2216,"toc":2867},[2217,2220,2223,2236,2239,2245,2248,2251,2300,2303,2307,2520,2524,2664,2668,2810,2814,2841,2844,2861,2864],[13,2218,2219],{},"这是一篇专门用来测试文章排版的内容。真实博客经常会同时出现说明文字、代码、表格和引用，如果只用普通段落测试，很容易漏掉细节。",[28,2221,2222],{"id":2222},"行内代码",[13,2224,2225,2226,173,2229,173,2232,2235],{},"行内代码应该和正文保持同一行节奏，例如 ",[23,2227,2228],{},"pnpm build",[23,2230,2231],{},"queryCollection('articles')",[23,2233,2234],{},"const pageRef = ref(null)","。它需要足够明显，但不能像按钮一样抢眼。",[28,2237,2238],{"id":2238},"引用",[2240,2241,2242],"blockquote",{},[13,2243,2244],{},"好的代码块样式应该让读者先看到结构，再决定是否阅读细节。",[13,2246,2247],{},"引用适合放设计原则、外部观点或阶段性结论。它和正文之间需要有清晰区分，但不应该像警告框一样沉重。",[28,2249,2250],{"id":2250},"表格",[403,2252,2253,2265],{},[406,2254,2255],{},[409,2256,2257,2260,2262],{},[412,2258,2259],{},"场景",[412,2261,1240],{},[412,2263,2264],{},"检查点",[422,2266,2267,2278,2289],{},[409,2268,2269,2272,2275],{},[427,2270,2271],{},"文章正文",[427,2273,2274],{},"长文阅读",[427,2276,2277],{},"行高、段落间距、标题层级",[409,2279,2280,2283,2286],{},[427,2281,2282],{},"代码块",[427,2284,2285],{},"技术记录",[427,2287,2288],{},"横向滚动、字体、背景对比",[409,2290,2291,2294,2297],{},[427,2292,2293],{},"移动端",[427,2295,2296],{},"窄屏阅读",[427,2298,2299],{},"表格溢出、代码换行、触控距离",[13,2301,2302],{},"表格在移动端最容易出问题。最稳妥的策略是允许横向滚动，而不是强行压缩每一列。",[28,2304,2306],{"id":2305},"typescript-示例","TypeScript 示例",[38,2308,2310],{"className":187,"code":2309,"language":190,"meta":45,"style":45},"type ArticleMeta = {\n  title: string\n  tags: string[]\n  views: number\n}\n\nfunction formatViews(value: number) {\n  return value.toLocaleString('zh-CN')\n}\n\nconst article: ArticleMeta = {\n  title: 'Markdown 与代码块渲染测试',\n  tags: ['Markdown', 'Nuxt', '代码高亮'],\n  views: 638\n}\n\nconsole.log(`${article.title}: ${formatViews(article.views)}`)\n",[23,2311,2312,2324,2334,2346,2356,2360,2364,2384,2401,2405,2409,2424,2435,2457,2466,2471,2476],{"__ignoreMap":45},[80,2313,2314,2317,2320,2322],{"class":82,"line":83},[80,2315,2316],{"class":197},"type",[80,2318,2319],{"class":207}," ArticleMeta",[80,2321,204],{"class":197},[80,2323,1983],{"class":90},[80,2325,2326,2329,2331],{"class":82,"line":98},[80,2327,2328],{"class":232},"  title",[80,2330,1318],{"class":197},[80,2332,2333],{"class":94}," string\n",[80,2335,2336,2339,2341,2343],{"class":82,"line":248},[80,2337,2338],{"class":232},"  tags",[80,2340,1318],{"class":197},[80,2342,1321],{"class":94},[80,2344,2345],{"class":90},"[]\n",[80,2347,2348,2351,2353],{"class":82,"line":255},[80,2349,2350],{"class":232},"  views",[80,2352,1318],{"class":197},[80,2354,2355],{"class":94}," number\n",[80,2357,2358],{"class":82,"line":270},[80,2359,307],{"class":90},[80,2361,2362],{"class":82,"line":304},[80,2363,252],{"emptyLinePlaceholder":251},[80,2365,2366,2368,2371,2373,2376,2378,2381],{"class":82,"line":310},[80,2367,1308],{"class":197},[80,2369,2370],{"class":207}," formatViews",[80,2372,282],{"class":90},[80,2374,2375],{"class":232},"value",[80,2377,1318],{"class":197},[80,2379,2380],{"class":94}," number",[80,2382,2383],{"class":90},") {\n",[80,2385,2386,2388,2391,2394,2396,2399],{"class":82,"line":315},[80,2387,1417],{"class":197},[80,2389,2390],{"class":90}," value.",[80,2392,2393],{"class":207},"toLocaleString",[80,2395,282],{"class":90},[80,2397,2398],{"class":285},"'zh-CN'",[80,2400,301],{"class":90},[80,2402,2403],{"class":82,"line":351},[80,2404,307],{"class":90},[80,2406,2407],{"class":82,"line":359},[80,2408,252],{"emptyLinePlaceholder":251},[80,2410,2411,2413,2416,2418,2420,2422],{"class":82,"line":380},[80,2412,198],{"class":197},[80,2414,2415],{"class":94}," article",[80,2417,1318],{"class":197},[80,2419,2319],{"class":207},[80,2421,204],{"class":197},[80,2423,1983],{"class":90},[80,2425,2427,2430,2433],{"class":82,"line":2426},12,[80,2428,2429],{"class":90},"  title: ",[80,2431,2432],{"class":285},"'Markdown 与代码块渲染测试'",[80,2434,2010],{"class":90},[80,2436,2438,2441,2444,2446,2449,2451,2454],{"class":82,"line":2437},13,[80,2439,2440],{"class":90},"  tags: [",[80,2442,2443],{"class":285},"'Markdown'",[80,2445,335],{"class":90},[80,2447,2448],{"class":285},"'Nuxt'",[80,2450,335],{"class":90},[80,2452,2453],{"class":285},"'代码高亮'",[80,2455,2456],{"class":90},"],\n",[80,2458,2460,2463],{"class":82,"line":2459},14,[80,2461,2462],{"class":90},"  views: ",[80,2464,2465],{"class":94},"638\n",[80,2467,2469],{"class":82,"line":2468},15,[80,2470,307],{"class":90},[80,2472,2474],{"class":82,"line":2473},16,[80,2475,252],{"emptyLinePlaceholder":251},[80,2477,2479,2482,2485,2487,2490,2493,2495,2498,2501,2504,2506,2508,2510,2513,2515,2518],{"class":82,"line":2478},17,[80,2480,2481],{"class":90},"console.",[80,2483,2484],{"class":207},"log",[80,2486,282],{"class":90},[80,2488,2489],{"class":285},"`${",[80,2491,2492],{"class":90},"article",[80,2494,292],{"class":285},[80,2496,2497],{"class":90},"title",[80,2499,2500],{"class":285},"}: ${",[80,2502,2503],{"class":207},"formatViews",[80,2505,282],{"class":285},[80,2507,2492],{"class":90},[80,2509,292],{"class":285},[80,2511,2512],{"class":90},"views",[80,2514,595],{"class":285},[80,2516,2517],{"class":285},"}`",[80,2519,301],{"class":90},[28,2521,2523],{"id":2522},"vue-示例","Vue 示例",[38,2525,2529],{"className":2526,"code":2527,"language":2528,"meta":45,"style":45},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup lang=\"ts\">\nconst count = ref(0)\nconst doubled = computed(() => count.value * 2)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton type=\"button\" @click=\"count++\">\n    点击 {{ count }} 次，结果 {{ doubled }}\n  \u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n","vue",[23,2530,2531,2551,2568,2594,2603,2607,2616,2642,2647,2656],{"__ignoreMap":45},[80,2532,2533,2535,2538,2541,2544,2546,2549],{"class":82,"line":83},[80,2534,1056],{"class":90},[80,2536,2537],{"class":86},"script",[80,2539,2540],{"class":207}," setup",[80,2542,2543],{"class":207}," lang",[80,2545,552],{"class":90},[80,2547,2548],{"class":285},"\"ts\"",[80,2550,1084],{"class":90},[80,2552,2553,2555,2557,2559,2562,2564,2566],{"class":82,"line":98},[80,2554,198],{"class":197},[80,2556,1373],{"class":94},[80,2558,204],{"class":197},[80,2560,2561],{"class":207}," ref",[80,2563,282],{"class":90},[80,2565,720],{"class":94},[80,2567,301],{"class":90},[80,2569,2570,2572,2575,2577,2580,2582,2584,2587,2589,2592],{"class":82,"line":248},[80,2571,198],{"class":197},[80,2573,2574],{"class":94}," doubled",[80,2576,204],{"class":197},[80,2578,2579],{"class":207}," computed",[80,2581,1978],{"class":90},[80,2583,239],{"class":197},[80,2585,2586],{"class":90}," count.value ",[80,2588,528],{"class":197},[80,2590,2591],{"class":94}," 2",[80,2593,301],{"class":90},[80,2595,2596,2599,2601],{"class":82,"line":255},[80,2597,2598],{"class":90},"\u003C\u002F",[80,2600,2537],{"class":86},[80,2602,1084],{"class":90},[80,2604,2605],{"class":82,"line":270},[80,2606,252],{"emptyLinePlaceholder":251},[80,2608,2609,2611,2614],{"class":82,"line":304},[80,2610,1056],{"class":90},[80,2612,2613],{"class":86},"template",[80,2615,1084],{"class":90},[80,2617,2618,2621,2624,2627,2629,2632,2635,2637,2640],{"class":82,"line":310},[80,2619,2620],{"class":90},"  \u003C",[80,2622,2623],{"class":86},"button",[80,2625,2626],{"class":207}," type",[80,2628,552],{"class":90},[80,2630,2631],{"class":285},"\"button\"",[80,2633,2634],{"class":207}," @click",[80,2636,552],{"class":90},[80,2638,2639],{"class":285},"\"count++\"",[80,2641,1084],{"class":90},[80,2643,2644],{"class":82,"line":315},[80,2645,2646],{"class":90},"    点击 {{ count }} 次，结果 {{ doubled }}\n",[80,2648,2649,2652,2654],{"class":82,"line":351},[80,2650,2651],{"class":90},"  \u003C\u002F",[80,2653,2623],{"class":86},[80,2655,1084],{"class":90},[80,2657,2658,2660,2662],{"class":82,"line":359},[80,2659,2598],{"class":90},[80,2661,2613],{"class":86},[80,2663,1084],{"class":90},[28,2665,2667],{"id":2666},"css-示例","CSS 示例",[38,2669,2673],{"className":2670,"code":2671,"language":2672,"meta":45,"style":45},"language-css shiki shiki-themes github-light github-dark",".code-card {\n  display: grid;\n  gap: 12px;\n  border: 1px solid var(--line);\n  border-radius: 8px;\n  background: var(--surface);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .code-card {\n    transition: none;\n  }\n}\n","css",[23,2674,2675,2682,2695,2710,2735,2749,2766,2770,2774,2782,2789,2801,2806],{"__ignoreMap":45},[80,2676,2677,2680],{"class":82,"line":83},[80,2678,2679],{"class":207},".code-card",[80,2681,1983],{"class":90},[80,2683,2684,2687,2689,2692],{"class":82,"line":98},[80,2685,2686],{"class":94},"  display",[80,2688,91],{"class":90},[80,2690,2691],{"class":94},"grid",[80,2693,2694],{"class":90},";\n",[80,2696,2697,2700,2702,2705,2708],{"class":82,"line":248},[80,2698,2699],{"class":94},"  gap",[80,2701,91],{"class":90},[80,2703,2704],{"class":94},"12",[80,2706,2707],{"class":197},"px",[80,2709,2694],{"class":90},[80,2711,2712,2715,2717,2719,2721,2724,2727,2729,2732],{"class":82,"line":255},[80,2713,2714],{"class":94},"  border",[80,2716,91],{"class":90},[80,2718,695],{"class":94},[80,2720,2707],{"class":197},[80,2722,2723],{"class":94}," solid",[80,2725,2726],{"class":94}," var",[80,2728,282],{"class":90},[80,2730,2731],{"class":232},"--line",[80,2733,2734],{"class":90},");\n",[80,2736,2737,2740,2742,2745,2747],{"class":82,"line":270},[80,2738,2739],{"class":94},"  border-radius",[80,2741,91],{"class":90},[80,2743,2744],{"class":94},"8",[80,2746,2707],{"class":197},[80,2748,2694],{"class":90},[80,2750,2751,2754,2756,2759,2761,2764],{"class":82,"line":304},[80,2752,2753],{"class":94},"  background",[80,2755,91],{"class":90},[80,2757,2758],{"class":94},"var",[80,2760,282],{"class":90},[80,2762,2763],{"class":232},"--surface",[80,2765,2734],{"class":90},[80,2767,2768],{"class":82,"line":310},[80,2769,307],{"class":90},[80,2771,2772],{"class":82,"line":315},[80,2773,252],{"emptyLinePlaceholder":251},[80,2775,2776,2779],{"class":82,"line":351},[80,2777,2778],{"class":197},"@media",[80,2780,2781],{"class":90}," (prefers-reduced-motion: reduce) {\n",[80,2783,2784,2787],{"class":82,"line":359},[80,2785,2786],{"class":207},"  .code-card",[80,2788,1983],{"class":90},[80,2790,2791,2794,2796,2799],{"class":82,"line":380},[80,2792,2793],{"class":94},"    transition",[80,2795,91],{"class":90},[80,2797,2798],{"class":94},"none",[80,2800,2694],{"class":90},[80,2802,2803],{"class":82,"line":2426},[80,2804,2805],{"class":90},"  }\n",[80,2807,2808],{"class":82,"line":2437},[80,2809,307],{"class":90},[28,2811,2813],{"id":2812},"bash-示例","Bash 示例",[38,2815,2817],{"className":821,"code":2816,"language":824,"meta":45,"style":45},"pnpm install --frozen-lockfile\npnpm typecheck\npnpm build\n",[23,2818,2819,2829,2835],{"__ignoreMap":45},[80,2820,2821,2823,2826],{"class":82,"line":83},[80,2822,831],{"class":207},[80,2824,2825],{"class":285}," install",[80,2827,2828],{"class":94}," --frozen-lockfile\n",[80,2830,2831,2833],{"class":82,"line":98},[80,2832,831],{"class":207},[80,2834,855],{"class":285},[80,2836,2837,2839],{"class":82,"line":248},[80,2838,831],{"class":207},[80,2840,862],{"class":285},[28,2842,2843],{"id":2843},"检查清单",[952,2845,2846,2849,2852,2855,2858],{},[902,2847,2848],{},"标题层级是否清晰。",[902,2850,2851],{},"代码块是否有足够的横向空间。",[902,2853,2854],{},"深色主题下代码背景是否刺眼。",[902,2856,2857],{},"移动端表格是否不会撑破页面。",[902,2859,2860],{},"中文和英文混排是否稳定。",[13,2862,2863],{},"这篇文章以后可以作为样式回归测试。每次调整文章内容样式，都先打开它看一遍。",[985,2865,2866],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":45,"searchDepth":248,"depth":248,"links":2868},[2869,2870,2871,2872,2873,2874,2875,2876],{"id":2222,"depth":98,"text":2222},{"id":2238,"depth":98,"text":2238},{"id":2250,"depth":98,"text":2250},{"id":2305,"depth":98,"text":2306},{"id":2522,"depth":98,"text":2523},{"id":2666,"depth":98,"text":2667},{"id":2812,"depth":98,"text":2813},{"id":2843,"depth":98,"text":2843},"\u002Farticles\u002Fmarkdown-code-fixture","2026-06-20","用一篇文章覆盖标题、引用、表格、列表、行内代码和多语言代码块。",{},"9 分钟",{"title":2214,"description":2879},"检查 Nuxt Content 中的标题、引用、表格、列表、行内代码和多语言代码块渲染效果。","Nuxt Markdown 与多语言代码块渲染测试","articles\u002Fmarkdown-code-fixture",[1013,1014,1586],"2026-07-13","dAqrDVzuLUeJdkPQCG4R4a3GL4C5FTHKztnyt_C4yL8",{"id":2890,"title":2891,"accent":2892,"author":8,"body":2893,"canonical":3268,"category":1000,"cover":3269,"date":1914,"description":3270,"draft":1004,"extension":1005,"indexable":251,"meta":3271,"navigation":251,"path":3268,"readingTime":2118,"seo":3272,"seoDescription":3273,"seoTitle":3274,"stem":3275,"tags":3276,"updated":1914,"__hash__":3278},"articles\u002Farticles\u002Fprivacy-friendly-nuxt-analytics.md","在 Nuxt 中实现克制的访问统计","#2aa7a1",{"type":10,"value":2894,"toc":3257},[2895,2898,2901,2904,2907,2915,2951,2954,2958,2961,2964,2987,2990,2994,2997,3006,3013,3016,3027,3030,3033,3048,3061,3078,3081,3084,3087,3090,3096,3099,3102,3108,3118,3121,3182,3185,3192,3199,3202,3205,3225,3228,3231,3234,3237,3251,3254],[13,2896,2897],{},"个人博客需要多少访问统计？对 EchoBlog 来说，答案不是完整用户画像，也不是一套复杂分析后台，而是三个足够回答维护问题的信号：站点最近是否有人访问、哪些内容被打开、最近七天的变化是否异常。",[13,2899,2900],{},"这组需求看似只需要一个计数器，真正实现时却会遇到预取请求、搜索爬虫、SPA 路由、重复刷新、隐私偏好和历史样例数据。本文记录当前实现怎样在“有用”和“克制”之间取舍，也明确它还不能替代专业分析服务的部分。",[28,2902,2903],{"id":2903},"先把读取和记录拆开",[13,2905,2906],{},"旧版接口在读取统计时同时增加计数。浏览器预取、服务端渲染、健康检查和搜索爬虫都可能发起 GET，于是一个看似普通的读取动作会悄悄改变数据。",[13,2908,2909,2914],{},[54,2910,2913],{"href":2911,"rel":2912},"https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc9110.html#name-safe-methods",[58],"RFC 9110 对安全方法的定义","强调，GET 的语义应当基本只读，自动抓取和预取也正依赖这个约束。现在接口被拆成两种职责：",[38,2916,2921],{"className":2917,"code":2918,"filename":2919,"language":2920,"meta":45,"style":45},"language-http shiki shiki-themes github-light github-dark","GET \u002Fapi\u002Fvisits\n\nPOST \u002Fapi\u002Fvisits\nContent-Type: application\u002Fjson\n\n{ \"path\": \"\u002Farticles\u002Fprivacy-friendly-nuxt-analytics\" }\n","request","http",[23,2922,2923,2928,2932,2937,2942,2946],{"__ignoreMap":45},[80,2924,2925],{"class":82,"line":83},[80,2926,2927],{},"GET \u002Fapi\u002Fvisits\n",[80,2929,2930],{"class":82,"line":98},[80,2931,252],{"emptyLinePlaceholder":251},[80,2933,2934],{"class":82,"line":248},[80,2935,2936],{},"POST \u002Fapi\u002Fvisits\n",[80,2938,2939],{"class":82,"line":255},[80,2940,2941],{},"Content-Type: application\u002Fjson\n",[80,2943,2944],{"class":82,"line":270},[80,2945,252],{"emptyLinePlaceholder":251},[80,2947,2948],{"class":82,"line":304},[80,2949,2950],{},"{ \"path\": \"\u002Farticles\u002Fprivacy-friendly-nuxt-analytics\" }\n",[13,2952,2953],{},"GET 只返回统计快照，不写存储，也不创建 Cookie。只有浏览器完成挂载或发生真实 SPA 路由切换后，客户端插件才发送 POST。这样构建、SSR 和巡检不会再制造访问量。",[28,2955,2957],{"id":2956},"统计的是会话不是每一次请求","统计的是会话，不是每一次请求",[13,2959,2960],{},"首页显示的“总访问次数”以 30 分钟活动会话为口径。同一会话中刷新页面或继续浏览，不会重复增加站点总访问；不同路径则各自最多记录一次，用于文章阅读量与热门排序。",[13,2962,2963],{},"服务端只设置一个短期 HttpOnly 会话 Cookie，并具备以下属性：",[952,2965,2966,2972,2978,2984],{},[902,2967,2968,2971],{},[23,2969,2970],{},"Max-Age"," 为 30 分钟，每次有效浏览会刷新窗口。",[902,2973,2974,2977],{},[23,2975,2976],{},"SameSite=Lax","，不参与跨站请求。",[902,2979,2980,2981,143],{},"HTTPS 下启用 ",[23,2982,2983],{},"Secure",[902,2985,2986],{},"不保存姓名、账户、邮箱或业务身份。",[13,2988,2989],{},"客户端也使用同一个 30 分钟常量抑制重复上报，避免前后端各自维护一个容易漂移的时间窗口。",[28,2991,2993],{"id":2992},"每日-uv-不需要长期访客-id","每日 UV 不需要长期访客 ID",[13,2995,2996],{},"一开始的实现使用一年期随机访客 Cookie，再把它哈希后写入服务端。它已经比保存原始标识更克制，但仍然允许跨日关联同一浏览器。",[13,2998,2999,3000,3005],{},"参考 ",[54,3001,3004],{"href":3002,"rel":3003},"https:\u002F\u002Fplausible.io\u002Fdata-policy",[58],"Plausible 公布的数据策略","，当前版本改成按日匿名摘要：请求中的客户端地址与 User-Agent 只在内存中参与 HMAC，服务端只保存摘要键和聚合数字。",[38,3007,3011],{"className":3008,"code":3009,"filename":3010,"language":44,"meta":45},[41],"dailyDigest = HMAC-SHA256(\n  dailySalt,\n  clientAddress + userAgent\n)\n","formula",[23,3012,3009],{"__ignoreMap":45},[13,3014,3015],{},"每日盐值随机生成，并在下一天清理。即使两个日期来自同一设备，摘要也无法直接关联；原始地址和完整 User-Agent 不会写入统计存储。无法获得客户端地址时，则使用短期会话 ID 作为降级输入。",[13,3017,3018,3019,3022,3023,3026],{},"反向代理头不能无条件信任。EchoBlog 的 Node 进程只监听 ",[23,3020,3021],{},"127.0.0.1","，代码仅在连接来自本机 Nginx 时读取 ",[23,3024,3025],{},"X-Forwarded-For","；如果以后把 Node 端口直接暴露到公网，转发头不会参与摘要计算。",[13,3028,3029],{},"每日 UV 是按天去重后的访客数，七日 UV 是七个每日数字之和，并不尝试跨日建立用户身份。这正是刻意保留的边界。",[28,3031,3032],{"id":3032},"明确尊重隐私信号",[13,3034,3035,3040,3041,909,3044,3047],{},[54,3036,3039],{"href":3037,"rel":3038},"https:\u002F\u002Fw3c.github.io\u002Fgpc\u002F",[58],"Global Privacy Control 规范","定义了 ",[23,3042,3043],{},"Sec-GPC: 1",[23,3045,3046],{},"navigator.globalPrivacyControl","。当浏览器表达该偏好时，EchoBlog 只读取公开统计，不写入会话或访客记录。",[13,3049,3050,3051,3056,3057,3060],{},"DNT 已经被弃用并由 GPC 接替，但 ",[54,3052,3055],{"href":3053,"rel":3054},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FHTTP\u002FReference\u002FHeaders\u002FDNT",[58],"MDN 的 DNT 文档","仍建议兼容已有的 ",[23,3058,3059],{},"DNT: 1"," 信号。因此客户端和服务端都会检查两者，同时过滤：",[952,3062,3063,3072,3075],{},[902,3064,3065,1473,3068,3071],{},[23,3066,3067],{},"Purpose",[23,3069,3070],{},"Sec-Purpose"," 中的预取请求。",[902,3073,3074],{},"常见爬虫、Lighthouse、预览器和命令行客户端 User-Agent。",[902,3076,3077],{},"不在站点真实路由集合中的路径。",[13,3079,3080],{},"这里的目标不是声称自动满足所有地区法律，而是让实现的默认行为与站点的实际需求相称。法律合规仍需要结合部署地区、隐私声明和具体数据用途单独判断。",[28,3082,3083],{"id":3083},"路由白名单控制数据边界",[13,3085,3086],{},"只用正则限制路径格式还不够。攻击者仍可以不断提交看起来合法但不存在的 slug，让本地 KV 文件持续增长。",[13,3088,3089],{},"Nitro 第一次处理记录请求时，会从 Nuxt Content 文章、项目集合和 taxonomy 注册表建立真实路由集合。POST 中的路径必须同时通过格式规范化和集合校验：",[38,3091,3094],{"className":3092,"code":3093,"language":44,"meta":45},[41],"静态页面 + 已发布文章 + 已登记项目 + 标签页 + 分类页\n",[23,3095,3093],{"__ignoreMap":45},[13,3097,3098],{},"查询参数、hash、API 地址和不存在的内容 slug 都不会创建统计键。部署新内容时 Node 进程会重新启动，路由缓存也会随新版本重建。",[28,3100,3101],{"id":3101},"文章阅读量属于运行时数据",[13,3103,3104,3105,3107],{},"过去每篇 Markdown 都写着一个 ",[23,3106,2512],{}," 数字。它让列表看起来完整，却无法反映真实阅读，也要求作者手工维护运行时数据。",[13,3109,3110,3111,3114,3115,3117],{},"现在文章 Frontmatter 只描述内容事实，阅读量从 ",[23,3112,3113],{},"\u002Farticles\u002F:slug"," 的路径计数派生。内容创建脚本不再生成 ",[23,3116,2512],{},"，发布校验也会拒绝手写该字段。统计尚未加载时，界面显示破折号而不是假装为零；加载完成后，文章列表、详情页和热门排序共用同一份状态。",[13,3119,3120],{},"这使内容和运行时数据各自拥有明确的来源：",[403,3122,3123,3136],{},[406,3124,3125],{},[409,3126,3127,3130,3133],{},[412,3128,3129],{},"数据",[412,3131,3132],{},"来源",[412,3134,3135],{},"生命周期",[422,3137,3138,3149,3160,3171],{},[409,3139,3140,3143,3146],{},[427,3141,3142],{},"标题、摘要、标签",[427,3144,3145],{},"Markdown Frontmatter",[427,3147,3148],{},"随内容版本发布",[409,3150,3151,3154,3157],{},[427,3152,3153],{},"总访问与七日趋势",[427,3155,3156],{},"Nitro storage",[427,3158,3159],{},"随线上访问更新",[409,3161,3162,3165,3168],{},[427,3163,3164],{},"文章阅读量",[427,3166,3167],{},"真实路径计数",[427,3169,3170],{},"随会话访问更新",[409,3172,3173,3176,3179],{},[427,3174,3175],{},"每日 UV",[427,3177,3178],{},"每日匿名摘要",[427,3180,3181],{},"摘要键保留 8 天，聚合数字保留",[28,3183,3184],{"id":3184},"迁移不能把样例数字当历史",[13,3186,3187,3188,3191],{},"原型阶段首页曾以 ",[23,3189,3190],{},"12,048"," 作为展示基线。如果直接上线新逻辑，这个数字会永久混入真实总数。",[13,3193,3194,3195,3198],{},"存储因此带有版本号。第一次写入 v2 统计时，迁移函数只移除一次演示基线，并保留之后已经产生的真实增量。升级后的首次记录还会扫描旧版每日数据：",[23,3196,3197],{},"visitors[]"," 只用于恢复已有 UV 数字，随后整批移除，历史记录只留下日期、PV 和 UV。",[28,3200,3201],{"id":3201},"测试关注口径而不只是返回值",[13,3203,3204],{},"统计逻辑至少需要覆盖以下不变量：",[899,3206,3207,3210,3213,3216,3219,3222],{},[902,3208,3209],{},"GET 不写数据、不创建 Cookie。",[902,3211,3212],{},"同一会话不会重复增加站点访问和同一路径计数。",[902,3214,3215],{},"每日盐值变化后，摘要不能跨日相等。",[902,3217,3218],{},"只有来自本机反向代理的转发地址可以被信任。",[902,3220,3221],{},"GPC、DNT、预取和爬虫请求不会进入统计。",[902,3223,3224],{},"不存在的路由不能污染存储。",[13,3226,3227],{},"项目把纯摘要、路径规范化和隐私信号判断放进单元测试，再用生产构建后的接口冒烟验证 Cookie、状态码和路径拒绝行为。相比只断言一个数字增加，这些测试更能保护统计口径。",[28,3229,3230],{"id":3230},"什么时候应该换成专业分析服务",[13,3232,3233],{},"当前实现使用 Nitro 文件存储和进程内串行写入，适合单机、低流量个人站。它不提供来源分析、转化事件、运营后台或多实例一致性。",[13,3235,3236],{},"出现以下条件时，就应迁移到 SQLite、PostgreSQL，或者使用 Umami、Plausible 等专门服务：",[952,3238,3239,3242,3245,3248],{},[902,3240,3241],{},"Node 服务开始多实例运行。",[902,3243,3244],{},"需要可靠的来源、地区或转化分析。",[902,3246,3247],{},"本地 KV 写入和清理开始影响响应时间。",[902,3249,3250],{},"需要可审计的数据保留和删除策略。",[13,3252,3253],{},"在此之前，少采集、短保留、清楚表达口径，比提前搭建一套复杂分析平台更符合这个个人站的实际目标。",[985,3255,3256],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":45,"searchDepth":248,"depth":248,"links":3258},[3259,3260,3261,3262,3263,3264,3265,3266,3267],{"id":2903,"depth":98,"text":2903},{"id":2956,"depth":98,"text":2957},{"id":2992,"depth":98,"text":2993},{"id":3032,"depth":98,"text":3032},{"id":3083,"depth":98,"text":3083},{"id":3101,"depth":98,"text":3101},{"id":3184,"depth":98,"text":3184},{"id":3201,"depth":98,"text":3201},{"id":3230,"depth":98,"text":3230},"\u002Farticles\u002Fprivacy-friendly-nuxt-analytics","\u002Fimages\u002Foptimized\u002Fpost-privacy-analytics.jpg","复盘 EchoBlog 如何区分只读查询与访问记录，用短期会话、每日匿名摘要和路由白名单得到足够有用的站点趋势。",{},{"title":2891,"description":3270},"复盘 EchoBlog 如何使用 Nitro storage、30 分钟会话、每日 HMAC 摘要、真实路由白名单和 GPC\u002FDNT 过滤实现轻量访问统计。","Nuxt 隐私友好访问统计实践：会话、每日 UV 与路由计数","articles\u002Fprivacy-friendly-nuxt-analytics",[1014,1015,3277],"访问统计","YzQupqsC-3hJ8vo5maiY6RaEfeyhY96s1CKGbH9Uvck",{"id":3280,"title":3281,"accent":3282,"author":8,"body":3283,"canonical":1649,"category":3348,"cover":1651,"date":3349,"description":3350,"draft":1004,"extension":1005,"indexable":251,"meta":3351,"navigation":251,"path":3352,"readingTime":1656,"seo":3353,"seoDescription":1649,"seoTitle":1649,"stem":3354,"tags":3355,"updated":1649,"__hash__":3358},"articles\u002Farticles\u002Ftag-system-for-life.md","给杂乱内容设计一套 DIY 标签","#f2b84b",{"type":10,"value":3284,"toc":3341},[3285,3288,3291,3294,3297,3300,3303,3306,3309,3312,3315,3318,3329,3332,3335,3338],[13,3286,3287],{},"个人博客的内容天然是杂的。强行分成几类，反而会让很多文章没有合适的位置。DIY 标签的价值在于承认这种混合。",[28,3289,3290],{"id":3290},"标签不是目录",[13,3292,3293],{},"目录强调稳定结构，标签强调临时连接。一篇文章可以同时属于技术、生活和灵感，这不是分类失败，而是个人表达的真实形态。",[13,3295,3296],{},"目录更适合长期稳定的知识体系，比如文档、课程或手册。博客更像不断生长的花园，今天的兴趣和明天的项目可能会互相连接。标签的意义就是保留这种连接，而不是过早把它们固定到某个抽屉里。",[28,3298,3299],{"id":3299},"标签要服务探索",[13,3301,3302],{},"标签系统不是为了管理后台好看，而是为了让读者更容易继续看下去。用户从一篇文章进入站点后，标签应该能告诉他：这里还有哪些相关内容，站主最近在关注什么，哪些主题已经形成连续记录。",[13,3304,3305],{},"因此标签需要出现在多个位置：文章列表、详情页、搜索结果、首页精选模块。只藏在文章底部的标签，很难真正发挥导航作用。",[28,3307,3308],{"id":3308},"控制数量",[13,3310,3311],{},"标签可以自由，但不能泛滥。我的做法是保留常用标签，再给阶段性兴趣一点空间。过期标签可以合并，也可以沉入归档。",[13,3313,3314],{},"一个实用的判断标准是：如果一个标签只有一篇文章，先不要急着把它提升为主要入口；如果它持续出现三次以上，就说明它可能值得被保留。这样既不会压制新兴趣，也不会让标签列表变成关键词垃圾场。",[28,3316,3317],{"id":3317},"允许别名",[13,3319,3320,3321,173,3323,173,3325,3328],{},"技术内容经常会出现同义标签，比如 ",[23,3322,2123],{},[23,3324,2122],{},[23,3326,3327],{},"Motion","。它们不是完全相同，但在首页筛选里可以归到同一个入口。用户点击“动画”时，看到所有动效相关内容，比严格匹配某个词更符合直觉。",[13,3330,3331],{},"别名不一定要暴露给用户。内部可以维护一份映射表，外部只展示更稳定、更容易理解的标签。",[28,3333,3334],{"id":3334},"让标签可见",[13,3336,3337],{},"标签不只是文章详情页的元信息，它也应该出现在首页和搜索里，成为探索内容的入口。",[13,3339,3340],{},"我希望标签像站点里的小路，而不是数据库字段。它可以带用户从技术文章走到项目作品，也可以从生活记录走到桌面改造。个人站点的魅力，正是在这些不那么标准的连接里。",{"title":45,"searchDepth":248,"depth":248,"links":3342},[3343,3344,3345,3346,3347],{"id":3290,"depth":98,"text":3290},{"id":3299,"depth":98,"text":3299},{"id":3308,"depth":98,"text":3308},{"id":3317,"depth":98,"text":3317},{"id":3334,"depth":98,"text":3334},"信息组织","2026-05-29","让技术、生活、项目和灵感可以自然地混在一起。",{},"\u002Farticles\u002Ftag-system-for-life",{"title":3281,"description":3350},"articles\u002Ftag-system-for-life",[3356,3357,1016],"标签","博客","-adL53EVbdiq1HvIHEqGavylm0KerrvA3nNSzrjN7gw",{"id":3360,"title":3361,"accent":3362,"author":8,"body":3363,"canonical":1649,"category":3420,"cover":2113,"date":3421,"description":3422,"draft":1004,"extension":1005,"indexable":251,"meta":3423,"navigation":251,"path":3424,"readingTime":3425,"seo":3426,"seoDescription":1649,"seoTitle":1649,"stem":3427,"tags":3428,"updated":1649,"__hash__":3430},"articles\u002Farticles\u002Fweather-widget-notes.md","把天气和访问量放进个人主页","#36c7d6",{"type":10,"value":3364,"toc":3413},[3365,3368,3371,3374,3377,3380,3383,3386,3389,3392,3395,3398,3401,3404,3407,3410],[13,3366,3367],{},"天气和访问量不是核心功能，但它们能让个人主页多一点“现在”的感觉。用户打开页面时，看到的不只是文章列表，也能感受到站点正在运行。",[28,3369,3370],{"id":3370},"天气要轻",[13,3372,3373],{},"天气模块不需要完整预报，只需要当前位置、温度、天气状态和更新时间。更多信息会让首页变得像工具面板。",[13,3375,3376],{},"我会把天气拆成三层信息：第一层是温度，第二层是天气状态和温度范围，第三层才是湿度、风力、空气质量这些补充数据。这样用户一眼能看懂当前状态，也不会被太多数字打断。",[28,3378,3379],{"id":3379},"数据来源和缓存",[13,3381,3382],{},"天气接口不应该每次访问首页都实时请求。一个个人站点没有必要为每个访客都打一次第三方 API，缓存 15 到 30 分钟通常已经足够。这样既减少延迟，也避免接口不稳定时影响首屏。",[13,3384,3385],{},"接口失败时要有默认值，UI 不应该因为天气服务不可用就崩掉。默认值不需要假装准确，只要能保持布局稳定即可。",[28,3387,3388],{"id":3388},"统计要克制",[13,3390,3391],{},"访问量适合作为轻量反馈，不应该变成增长焦虑。个人站点更重要的是内容沉淀，而不是数字本身。",[13,3393,3394],{},"我更喜欢把访问量做成环境信息，而不是核心指标。它可以让页面有一点生命感，但不应该占据太大的视觉权重。对个人站来说，真正值得长期优化的是内容质量、加载速度和可读性。",[28,3396,3397],{"id":3397},"数据要可替换",[13,3399,3400],{},"原型阶段可以用本地计数，部署后再接入 Umami、Plausible 或其他统计服务。只要 UI 数据结构稳定，替换成本很低。",[13,3402,3403],{},"我会让前端只关心统一的数据结构：总访问量、今日访问量、本周访问量。至于这些数据来自 KV、数据库还是第三方统计，都应该藏在 API 后面。",[28,3405,3406],{"id":3406},"让状态更有情绪",[13,3408,3409],{},"天气是少数天然适合做微动效的数据。晴天可以有轻微耀斑，雨天可以有细雨线，雪天可以有慢速雪点。关键是动效要待在背景层，不影响文字阅读，也要尊重系统的减少动态效果设置。",[13,3411,3412],{},"好的信息卡片不只是显示数据，也能让用户感到“这个站点正在此刻运行”。",{"title":45,"searchDepth":248,"depth":248,"links":3414},[3415,3416,3417,3418,3419],{"id":3370,"depth":98,"text":3370},{"id":3379,"depth":98,"text":3379},{"id":3388,"depth":98,"text":3388},{"id":3397,"depth":98,"text":3397},{"id":3406,"depth":98,"text":3406},"产品设计","2026-05-21","微小的数据可以让静态页面更像一个活着的空间。",{},"\u002Farticles\u002Fweather-widget-notes","6 分钟",{"title":3361,"description":3422},"articles\u002Fweather-widget-notes",[3429,3277,1014],"天气","WXzNhM_uKkHYE0LAI2OZTO4UHPIxNJwd2xQUHU9mkYE",[3432,3547,3639,3722,3819],{"id":3433,"title":3434,"accent":7,"body":3435,"canonical":1649,"cover":1001,"description":3439,"draft":1004,"evidence":3440,"extension":1005,"features":3469,"indexable":251,"links":3476,"meta":3483,"navigation":251,"order":255,"overview":3484,"path":3487,"period":3488,"platform":3489,"roadmap":3490,"role":3495,"seo":3496,"seoDescription":1649,"seoTitle":1649,"showcase":3497,"stack":3523,"stage":3540,"statusNote":3541,"stem":3542,"tags":3543,"updated":1002,"year":3545,"__hash__":3546},"projects\u002Fprojects\u002Fcodepaper.md","CodePaper",{"type":10,"value":3436,"toc":3437},[],{"title":45,"searchDepth":248,"depth":248,"links":3438},[],"在线代码片段与长文分享工具，兼顾阅读和整理。",[3441,3447,3451,3454,3458,3462,3465],{"label":3442,"value":3443,"href":3444,"kind":3445,"note":3446},"可运行原型","在 EchoBlog 中打开 CodePaper","\u002Fprojects\u002Fcodepaper\u002Fdemo","verified","草稿仅保存在访问者当前浏览器",{"label":3448,"value":3449,"kind":3450},"安全边界","Marked 解析、高亮前后均经 DOMPurify 消毒","artifact",{"label":3452,"value":3453,"kind":3445},"阅读结构","二、三级标题目录与重复标题稳定锚点",{"label":3455,"value":3456,"href":1577,"kind":3445,"note":3457},"真实写作验证","浏览器 Markdown 安全预览实践","覆盖长文目录、表格、外链与多语言代码块",{"label":3459,"value":3460,"href":999,"kind":3445,"note":3461},"博客草稿交接","完整 Frontmatter、已登记分类标签与安全发布默认值","已验证安全草稿不进入生产构建，并完成一次人工复核后的正式发布",{"label":3463,"value":3464,"kind":3450},"数据边界","不上传草稿，不提供云同步",{"label":3466,"value":3467,"kind":3468},"公开源码","暂无公开仓库","unavailable",[3470,3471,3472,3473,3474,3475],"Markdown 长文编辑与实时预览","浏览器本地草稿、自动保存和恢复","Markdown 文件导入、复制与导出","完整 EchoBlog Frontmatter 的安全博客草稿导出","危险 HTML 消毒和响应式编辑模式","代码语言高亮、稳定标题锚点与当前章节目录",[3477,3480],{"label":3478,"href":3444,"note":3479},"演示地址","浏览器本地原型",{"label":3481,"note":3482},"源码地址","尚未建立公开仓库",{},[3485,3486],"CodePaper 是一个面向代码片段、长文和技术笔记的写作工具。它试图解决“代码解释”和“文章叙述”之间来回切换的问题。","项目重点放在 Markdown 渲染、代码块可读性、片段管理和长文结构上，适合作为技术博客编辑器或个人知识整理工具。","\u002Fprojects\u002Fcodepaper","2025 Q4 - 2026 Q1","Web Writing Tool",[3491,3492,3493,3494],"在真实写作出现重复整理阻塞后评估片段库和全文搜索","增加代码块复制和可选行号","支持可审查的 HTML 导出","评估独立部署和公开源码","编辑器体验 \u002F 前端架构 \u002F Markdown 渲染",{"title":3434,"description":3439},{"eyebrow":3498,"title":3499,"description":3500,"panels":3501},"Editor Sample","代码笔记编辑页示例","示例页把 Markdown 写作、代码片段和结构目录放在同一条工作流里，适合从零散片段整理成完整文章。",[3502,3509,3516],{"title":3503,"summary":3504,"items":3505},"分屏编辑","左侧写作，右侧预览，保持文章结构和代码块可读。",[3506,3507,3508],"支持 Markdown 基础语法","代码块显示语言与语法高亮","目录随标题自动生成",{"title":3510,"summary":3511,"items":3512},"片段库","常用代码可以先作为片段保存，再插入到长文。",[3513,3514,3515],"按语言和标签筛选","支持快速复制","记录片段来源",{"title":3517,"summary":3518,"items":3519},"导出流程","把写好的技术笔记导出为便携 Markdown 或安全博客草稿。",[3520,3521,3522],"导出 Markdown","生成完整 EchoBlog Frontmatter","默认保持草稿和不可索引",[3524,3529,3534],{"label":3525,"items":3526},"编辑器",[1013,3527,3528],"Code Highlight","Keyboard Flow",{"label":3530,"items":3531},"前端",[3532,1015,3533],"Vue 3","Composable Design",{"label":3535,"items":3536},"内容",[3537,3538,3539],"Snippet Model","Tag Search","Export Workflow","wip","已提供可运行的本地优先写作原型，并用两篇正式技术长文验证安全预览、目录导航、草稿恢复、博客草稿导出和 Git 发布门禁；协作、自动发布与独立源码不在当前版本内。","projects\u002Fcodepaper",[3532,1013,3544],"Editor","2025","kVENlnz_RXDt7yWyPTYobGFBFLYxhckBet9OlpEewak",{"id":3548,"title":3549,"accent":3362,"body":3550,"canonical":3554,"cover":3555,"description":3556,"draft":1004,"evidence":3557,"extension":1005,"features":3566,"indexable":251,"links":3571,"meta":3575,"navigation":251,"order":98,"overview":3576,"path":3554,"period":3579,"platform":3580,"roadmap":3581,"role":3586,"seo":3587,"seoDescription":3588,"seoTitle":3589,"showcase":3590,"stack":3616,"stage":3540,"statusNote":3632,"stem":3633,"tags":3634,"updated":3636,"year":3637,"__hash__":3638},"projects\u002Fprojects\u002Fecho-admin.md","Echo Admin",{"type":10,"value":3551,"toc":3552},[],{"title":45,"searchDepth":248,"depth":248,"links":3553},[],"\u002Fprojects\u002Fecho-admin","\u002Fimages\u002Foptimized\u002Fproject-echo-admin.jpg","基于 Vue 3 的后台管理系统，关注可读数据与稳定交互。",[3558,3561,3565],{"label":3559,"value":3560,"kind":3450},"当前产物","后台工作台信息架构与静态视觉稿",{"label":3562,"value":3563,"kind":3468,"note":3564},"可运行版本","暂无公开版本","权限、真实数据和批量操作仍属于后续实现范围",{"label":3466,"value":3467,"kind":3468},[3567,3568,3569,3570],"可读性优先的数据卡片和趋势图布局","侧边栏导航、顶部搜索、通知与账户入口","表格、日历、任务列表等高频后台组件草案","适配浅色二次元视觉，但控制装饰密度，保证长期使用不疲劳",[3572,3574],{"label":3478,"note":3573},"概念稿，无独立演示",{"label":3481,"note":3482},{},[3577,3578],"Echo Admin 是一个面向内容与运营场景的后台管理原型，重点不是堆满图表，而是让数据读取、筛选和操作路径足够稳定。","项目把首页概览、趋势图、任务列表和用户表格放在同一个工作台中，适合后续扩展成博客后台、项目管理台或个人数据中心。","2026 Q2 - 持续迭代","Web Dashboard",[3582,3583,3584,3585],"接入真实权限模型","补充表格筛选与批量操作","增加图表主题切换","整理成可复用后台模板","产品设计 \u002F 前端工程 \u002F 数据可视化",{"title":3549,"description":3556},"Echo Admin 是基于 Vue 3 与 TypeScript 的后台管理系统原型，专注可读数据、响应式布局与稳定交互。","Echo Admin 后台管理系统项目",{"eyebrow":3591,"title":3592,"description":3593,"panels":3594},"Dashboard Sample","后台工作台示例","把后台首页拆成可扫描的信息块，先让运营状态、任务风险和近期数据一眼可读，再进入具体表格处理。",[3595,3602,3609],{"title":3596,"summary":3597,"items":3598},"数据总览","用于首页首屏，展示访问、内容、任务和转化的关键指标。",[3599,3600,3601],"趋势图保留 7 日变化","卡片突出同比变化","异常状态用高亮提示",{"title":3603,"summary":3604,"items":3605},"内容队列","把草稿、待审核和已发布内容集中在一个列表里。",[3606,3607,3608],"按状态快速筛选","支持批量操作入口","保留更新时间与负责人",{"title":3610,"summary":3611,"items":3612},"运营日历","适合安排发布节奏、活动节点和复盘时间。",[3613,3614,3615],"月视图标记关键事件","今日任务固定展示","低优先级信息折叠",[3617,3620,3626],{"label":3530,"items":3618},[3532,1015,3619],"Vite",{"label":3621,"items":3622},"交互",[3623,3624,3625],"Dashboard Layout","Responsive Grid","Stateful Components",{"label":3627,"items":3628},"可视化",[3629,3630,3631],"Line Chart","KPI Cards","Calendar Panel","当前可核对的是后台工作台的产品范围与静态视觉稿；权限、数据和交互尚未形成公开可运行版本。","projects\u002Fecho-admin",[3532,1015,3635],"Dashboard","2026-07-24","2026","LUCQoyE94Imc0au1Ggd2xGsgkXPh09qspAXD0IhyWkc",{"id":3640,"title":3641,"accent":1927,"body":3642,"canonical":1649,"cover":3646,"description":3647,"draft":1004,"evidence":3648,"extension":1005,"features":3653,"indexable":251,"links":3658,"meta":3661,"navigation":251,"order":248,"overview":3662,"path":3665,"period":3666,"platform":3667,"roadmap":3668,"role":3673,"seo":3674,"seoDescription":1649,"seoTitle":1649,"showcase":3675,"stack":3701,"stage":3716,"statusNote":3717,"stem":3718,"tags":3719,"updated":3636,"year":3637,"__hash__":3721},"projects\u002Fprojects\u002Fecho-diary.md","Echo Diary",{"type":10,"value":3643,"toc":3644},[],{"title":45,"searchDepth":248,"depth":248,"links":3645},[],"\u002Fimages\u002Foptimized\u002Fproject-echo-diary.jpg","个人日记与灵感记录站，适合碎片想法沉淀。",[3649,3651,3652],{"label":3559,"value":3650,"kind":3450},"记录流程、信息架构与静态视觉稿",{"label":3562,"value":3563,"kind":3468},{"label":3466,"value":3467,"kind":3468},[3654,3655,3656,3657],"按日期沉淀日记和灵感片段","使用心情、天气、标签辅助回看","支持图片墙和短记录混排","用柔和动效降低记录工具的压力感",[3659,3660],{"label":3478,"note":3573},{"label":3481,"note":3482},{},[3663,3664],"Echo Diary 是一个给个人日记、灵感片段和创作素材使用的记录站。它更关注“轻量进入”和“后续回看”，而不是复杂的知识库结构。","原型把日历、心情、图片、短文本和灵感卡片放在同一个柔和界面中，让记录行为更接近日常习惯。","\u002Fprojects\u002Fecho-diary","2026 Q2","Personal Web App",[3669,3670,3671,3672],"完善编辑器体验","增加本地草稿恢复","设计月度回顾视图","探索私有部署同步","产品原型 \u002F Nuxt 开发 \u002F 内容体验",{"title":3641,"description":3647},{"eyebrow":3676,"title":3677,"description":3678,"panels":3679},"Diary Sample","日记记录页示例","示例页强调低压力输入：当天记录、情绪、天气、图片和短句可以先轻量保存，之后再慢慢整理。",[3680,3687,3694],{"title":3681,"summary":3682,"items":3683},"今日记录","默认进入当天时间线，减少选择成本。",[3684,3685,3686],"快捷记录短句","支持心情与天气标记","自动保存草稿",{"title":3688,"summary":3689,"items":3690},"灵感卡片","收纳突然出现的想法、标题、截图和待办。",[3691,3692,3693],"卡片可拖动排序","标签与颜色辅助分类","一键转成长文素材",{"title":3695,"summary":3696,"items":3697},"月度回看","用轻量统计和精选片段复盘一段时间的生活状态。",[3698,3699,3700],"按情绪和标签筛选","突出连续记录天数","保留图片墙入口",[3702,3707,3711],{"label":3703,"items":3704},"框架",[3705,3532,3706],"Nuxt 3","Nuxt Content",{"label":3535,"items":3708},[1013,3709,3710],"Tagging","Local Draft",{"label":3712,"items":3713},"体验",[2122,3714,3715],"View Transition","Responsive UI","concept","当前仅完成记录流程、信息架构与静态视觉探索，尚无可运行产品或公开源码。","projects\u002Fecho-diary",[3705,3720,3327],"Content","Y5X0MvDAE4md602lNhL1f8V2ajFIdeOKiYrTsyK48_U",{"id":3723,"title":3724,"accent":1666,"body":3725,"canonical":3729,"cover":1913,"description":3730,"draft":1004,"evidence":3731,"extension":1005,"features":3744,"indexable":251,"links":3751,"meta":3759,"navigation":251,"order":83,"overview":3760,"path":3729,"period":3763,"platform":3764,"roadmap":3765,"role":3769,"seo":3770,"seoDescription":3771,"seoTitle":3772,"showcase":3773,"stack":3799,"stage":3814,"statusNote":3815,"stem":3816,"tags":3817,"updated":3636,"year":3637,"__hash__":3818},"projects\u002Fprojects\u002Fechoblog.md","EchoBlog",{"type":10,"value":3726,"toc":3727},[],{"title":45,"searchDepth":248,"depth":248,"links":3728},[],"\u002Fprojects\u002Fechoblog","基于 Nuxt 4 的个人主页与博客，整合内容、作品集、搜索、天气、音乐和自动发布流程。",[3732,3736,3740],{"label":3733,"value":3734,"kind":3445,"href":3735},"在线版本","blog.echootaku.cn","https:\u002F\u002Fblog.echootaku.cn",{"label":3737,"value":3738,"kind":3445,"href":3739},"源码镜像","Gitee 公开仓库","https:\u002F\u002Fgitee.com\u002Fecho-otaku\u002Fecho-otaku-blog",{"label":3741,"value":3742,"kind":3445,"note":3743},"发布门禁","测试、类型检查、构建、冒烟、健康检查与失败回滚","生产站另有定时巡检覆盖页面、接口、SEO 与 CDN 媒体能力",[3745,3746,3747,3748,3749,3750],"使用 Nuxt Content 管理文章和项目，提供草稿隔离、Frontmatter 校验、标签分类和交互式内容创建流程","提供中文全文搜索、章节定位、文章目录、代码阅读、归档时间轴和活动热力图","集成天气与空气质量、访问统计、音乐播放器、歌词频谱和响应式灵动岛","完成 canonical、Open Graph、JSON-LD、robots、sitemap 和搜索引擎收录基础设施","支持内容级索引状态，测试夹具保持可访问但不向搜索引擎提交","通过 GitHub Actions 执行测试、类型检查、构建、资源预算、发布、健康检查和失败回滚",[3752,3754,3757],{"label":3753,"href":3735},"在线访问",{"label":3755,"note":3756},"GitHub 源码","仓库当前需要授权访问，暂不作为公开链接",{"label":3758,"href":3739},"Gitee 镜像",{},[3761,3762],"EchoBlog 是这个站点本身，也是一次从视觉原型走到可部署产品的完整实践。它把个人主页、文章、项目、归档和关于信息组织在同一套内容模型中。","项目除了页面呈现，也覆盖内容校验、全文搜索、SEO、资源预算、生产巡检、健康检查和自动回滚，让个人站点可以持续更新，而不是只停留在一次性的展示页。","2026 Q2 - 持续维护","Nuxt SSR \u002F Self-hosted",[3766,3767,3768],"持续补充真实文章、项目记录和维护日志","根据 Search Console 与生产巡检数据优化可发现性和加载体验","在流量或部署形态需要时升级访问统计持久层","产品设计 \u002F 全栈开发 \u002F 内容工程 \u002F 运维",{"title":3724,"description":3730},"EchoBlog 是基于 Nuxt 4、Nuxt Content 与 Nitro 构建的个人主页和博客，包含内容检索、作品集、天气音乐组件、SEO 与自动部署。","EchoBlog 个人博客与作品集项目",{"eyebrow":3774,"title":3775,"description":3776,"panels":3777},"Production Case","从个人主页到可持续维护的内容站","以真实发布链路为边界，把内容、交互、资源、SEO 和运维放进同一套可验证的工程流程。",[3778,3785,3792],{"title":3779,"summary":3780,"items":3781},"内容工作流","文章和项目使用结构化 Frontmatter，创建、校验、检索和发布共享同一份内容事实。",[3782,3783,3784],"草稿文件与发布内容物理隔离","标签和分类统一注册并动态计数","创建后自动执行内容合法性检查",{"title":3786,"summary":3787,"items":3788},"阅读与发现","从目录、归档和标签进入内容，再通过全文搜索定位到具体章节。",[3789,3790,3791],"中文分词与相关度排序","代码语言、复制按钮和双主题高亮","sitemap、结构化数据与 canonical",{"title":3793,"summary":3794,"items":3795},"发布与观测","每次发布都经过本地质量门禁，生产环境继续执行健康与媒体能力巡检。",[3796,3797,3798],"自动构建、发布、健康检查和失败回滚","图片与音乐 CDN 回退策略","核心页面、SEO 与资源预算巡检",[3800,3804,3809],{"label":3801,"items":3802},"应用",[3803,3532,1015],"Nuxt 4",{"label":3805,"items":3806},"内容与服务端",[3706,3807,3808],"Nitro","MiniSearch",{"label":3810,"items":3811},"交互与运维",[2122,3812,3813],"GitHub Actions","Nginx","active","正式站点已上线，源码镜像、部署链路和生产巡检均有公开或可运行证据。","projects\u002Fechoblog",[1014,1015,3807],"fDQqbAul06-7iniBEJS3DeKRjXdN563RVb47vWqaZeE",{"id":3820,"title":3821,"accent":3822,"body":3823,"canonical":1649,"cover":3827,"description":3828,"draft":1004,"evidence":3829,"extension":1005,"features":3836,"indexable":251,"links":3841,"meta":3845,"navigation":251,"order":270,"overview":3846,"path":3849,"period":3850,"platform":3851,"roadmap":3852,"role":3857,"seo":3858,"seoDescription":1649,"seoTitle":1649,"showcase":3859,"stack":3885,"stage":3716,"statusNote":3899,"stem":3900,"tags":3901,"updated":3636,"year":3545,"__hash__":3903},"projects\u002Fprojects\u002Fpetite-gallery.md","Petite Gallery","#2b8a7e",{"type":10,"value":3824,"toc":3825},[],{"title":45,"searchDepth":248,"depth":248,"links":3826},[],"\u002Fimages\u002Foptimized\u002Fproject-petite-gallery.jpg","轻量级图片画廊概念，探索照片和截图的低干扰浏览体验。",[3830,3832,3833],{"label":3559,"value":3831,"kind":3450},"浏览流程、响应式布局与静态视觉稿",{"label":3562,"value":3563,"kind":3468},{"label":3834,"value":3835,"kind":3468},"源码状态","私有仓库，暂不公开",[3837,3838,3839,3840],"瀑布流与网格两种图片浏览方式","图片分组、标签和轻量筛选","保留足够留白的沉浸式详情视图","针对低带宽场景优化缩略图加载",[3842,3843],{"label":3478,"note":3573},{"label":3481,"note":3844},"私有仓库",{},[3847,3848],"Petite Gallery 是一个轻量图片画廊展示站，目标是让照片、截图和插画在不同设备上都有稳定的观看体验。","它不追求复杂社区功能，而是把重点放在图片比例、加载顺序、分组浏览和留白节奏上。","\u002Fprojects\u002Fpetite-gallery","2025 Q3","Responsive Gallery",[3853,3854,3855,3856],"补充图片 EXIF 信息","增加键盘浏览","增加批量导入脚本","优化移动端大图手势","视觉设计 \u002F 图片展示 \u002F 前端实现",{"title":3821,"description":3828},{"eyebrow":3860,"title":3861,"description":3862,"panels":3863},"Gallery Sample","图片画廊浏览页示例","示例页围绕图片观看体验设计，先稳定缩略图比例和加载顺序，再进入沉浸式大图浏览。",[3864,3871,3878],{"title":3865,"summary":3866,"items":3867},"精选网格","首页保留均衡留白，让不同尺寸图片有统一节奏。",[3868,3869,3870],"固定比例缩略图","低带宽优先加载首屏","图片标题延迟展示",{"title":3872,"summary":3873,"items":3874},"分组浏览","按主题、时间和标签快速切换图片集合。",[3875,3876,3877],"标签筛选不刷新页面","保留当前位置","空状态提示下一步",{"title":3879,"summary":3880,"items":3881},"大图详情","点击图片后进入沉浸查看，减少周围 UI 干扰。",[3882,3883,3884],"键盘切换上一张下一张","展示 EXIF 摘要","移动端支持手势滑动",[3886,3889,3893],{"label":3703,"items":3887},[1014,3807,3888],"Image Assets",{"label":3712,"items":3890},[3891,3892,3624],"Lazy Loading","Aspect Ratio",{"label":3894,"items":3895},"部署",[3896,3897,3898],"Static Assets","Nginx Cache","Self-hosted","当前只完成图片浏览体验、响应式布局和加载策略的概念方案，尚无公开运行版本。","projects\u002Fpetite-gallery",[1014,3807,3902],"Gallery","WNL5vsNs3cSlWbL-NcE4AnJi4ygyetG728uODbPfkCE",1787629135563]