[{"data":1,"prerenderedAt":1412},["ShallowReactive",2],{"page-/post/nuxt/nuxt-content-v3-rss-done":3,"surrounding-page":1403},{"id":4,"title":5,"author":6,"body":7,"date":1389,"description":5,"extension":1390,"group":6,"lastmod":1391,"meta":1392,"navigation":596,"path":1393,"rawbody":1394,"seo":1395,"showTitle":6,"stem":1396,"tags":1397,"versions":1399,"__hash__":1402},"content/post/nuxt/nuxt-content-v3-rss-done.md","Nuxt Content v3 实现 RSS 订阅功能",null,{"type":8,"value":9,"toc":1382},"minimark",[10,14,38,45,49,71,273,279,339,351,358,368,372,375,386,397,400,403,445,499,502,517,520,541,544,547,550,555,957,960,970,1001,1007,1017,1027,1030,1033,1121,1128,1138,1140,1333,1346,1352,1355,1363,1369,1372,1375,1378],[11,12,13],"p",{},"这可能是对现在而言唯一一篇关于Content v3 版本的 RSS 订阅的文章了。我找遍了能搜出来的每篇文章，没有一个能用的。",[11,15,16,17,21,22,25,26,29,30,33,34,37],{},"因为 ",[18,19,20],"code",{},"Nuxt Content"," v3 还没发布正式版，其相关生态的 ",[18,23,24],{},"module"," 都没支持最新的 ",[18,27,28],{},"Content"," , 感觉不是很复杂的功能，但是就不跟  ",[18,31,32],{},"content v3"," 一起出个 ",[18,35,36],{},"alpha"," 版，很无语。",[11,39,40,41,44],{},"本文来带大家在 ",[18,42,43],{},"Nuxt Content v3"," 里实现 RSS 订阅功能。",[46,47,48],"h2",{"id":48},"添加原始内容",[11,50,51,52,55,56,59,60,63,64,67,68,70],{},"在 ",[18,53,54],{},"content.config.ts"," 中 ",[18,57,58],{},"rawbody"," 是一个特殊的 ",[18,61,62],{},"schema"," ，配置后，将会把md原始内容存起来，在使用 ",[18,65,66],{},"queryCollection"," 时，就可以查到 ",[18,69,58],{}," 了",[72,73,78],"pre",{"className":74,"code":75,"language":76,"meta":77,"style":77},"language-typescript shiki shiki-themes github-light","content: defineCollection({\n    type: 'page',\n    source: {\n      include: '**/*.md',\n      exclude: ['**/-*.md', 'book/**/*.md'],\n      prefix: '/post',\n      repository: 'https://github.com/aatrooox/xxxx',\n      authToken: process.env.CONTENT_REPO_TOKEN\n    },\n    schema: z.object({\n      date: z.date(),\n      lastmod: z.date(),\n      tags: z.array(z.string()),\n      versions: z.array(z.string()),\n      rawbody: z.string()\n    })\n  }),\n","typescript","",[18,79,80,99,112,118,129,147,158,169,179,185,196,208,218,236,250,261,267],{"__ignoreMap":77},[81,82,85,89,93,96],"span",{"class":83,"line":84},"line",1,[81,86,88],{"class":87},"s7eDp","content",[81,90,92],{"class":91},"sgsFI",": ",[81,94,95],{"class":87},"defineCollection",[81,97,98],{"class":91},"({\n",[81,100,102,105,109],{"class":83,"line":101},2,[81,103,104],{"class":91},"    type: ",[81,106,108],{"class":107},"sYBdl","'page'",[81,110,111],{"class":91},",\n",[81,113,115],{"class":83,"line":114},3,[81,116,117],{"class":91},"    source: {\n",[81,119,121,124,127],{"class":83,"line":120},4,[81,122,123],{"class":91},"      include: ",[81,125,126],{"class":107},"'**/*.md'",[81,128,111],{"class":91},[81,130,132,135,138,141,144],{"class":83,"line":131},5,[81,133,134],{"class":91},"      exclude: [",[81,136,137],{"class":107},"'**/-*.md'",[81,139,140],{"class":91},", ",[81,142,143],{"class":107},"'book/**/*.md'",[81,145,146],{"class":91},"],\n",[81,148,150,153,156],{"class":83,"line":149},6,[81,151,152],{"class":91},"      prefix: ",[81,154,155],{"class":107},"'/post'",[81,157,111],{"class":91},[81,159,161,164,167],{"class":83,"line":160},7,[81,162,163],{"class":91},"      repository: ",[81,165,166],{"class":107},"'https://github.com/aatrooox/xxxx'",[81,168,111],{"class":91},[81,170,172,175],{"class":83,"line":171},8,[81,173,174],{"class":91},"      authToken: process.env.",[81,176,178],{"class":177},"sYu0t","CONTENT_REPO_TOKEN\n",[81,180,182],{"class":83,"line":181},9,[81,183,184],{"class":91},"    },\n",[81,186,188,191,194],{"class":83,"line":187},10,[81,189,190],{"class":91},"    schema: z.",[81,192,193],{"class":87},"object",[81,195,98],{"class":91},[81,197,199,202,205],{"class":83,"line":198},11,[81,200,201],{"class":91},"      date: z.",[81,203,204],{"class":87},"date",[81,206,207],{"class":91},"(),\n",[81,209,211,214,216],{"class":83,"line":210},12,[81,212,213],{"class":91},"      lastmod: z.",[81,215,204],{"class":87},[81,217,207],{"class":91},[81,219,221,224,227,230,233],{"class":83,"line":220},13,[81,222,223],{"class":91},"      tags: z.",[81,225,226],{"class":87},"array",[81,228,229],{"class":91},"(z.",[81,231,232],{"class":87},"string",[81,234,235],{"class":91},"()),\n",[81,237,239,242,244,246,248],{"class":83,"line":238},14,[81,240,241],{"class":91},"      versions: z.",[81,243,226],{"class":87},[81,245,229],{"class":91},[81,247,232],{"class":87},[81,249,235],{"class":91},[81,251,253,256,258],{"class":83,"line":252},15,[81,254,255],{"class":91},"      rawbody: z.",[81,257,232],{"class":87},[81,259,260],{"class":91},"()\n",[81,262,264],{"class":83,"line":263},16,[81,265,266],{"class":91},"    })\n",[81,268,270],{"class":83,"line":269},17,[81,271,272],{"class":91},"  }),\n",[11,274,51,275,278],{},[18,276,277],{},"server"," 中查询时：",[72,280,282],{"className":74,"code":281,"language":76,"meta":77,"style":77},"// @ts-ignore\n  const posts = await queryCollection(event, 'content').order('date', \"DESC\").all();\n",[18,283,284,290],{"__ignoreMap":77},[81,285,286],{"class":83,"line":84},[81,287,289],{"class":288},"sAwPA","// @ts-ignore\n",[81,291,292,296,299,302,305,308,311,314,317,320,323,326,328,331,333,336],{"class":83,"line":101},[81,293,295],{"class":294},"sD7c4","  const",[81,297,298],{"class":177}," posts",[81,300,301],{"class":294}," =",[81,303,304],{"class":294}," await",[81,306,307],{"class":87}," queryCollection",[81,309,310],{"class":91},"(event, ",[81,312,313],{"class":107},"'content'",[81,315,316],{"class":91},").",[81,318,319],{"class":87},"order",[81,321,322],{"class":91},"(",[81,324,325],{"class":107},"'date'",[81,327,140],{"class":91},[81,329,330],{"class":107},"\"DESC\"",[81,332,316],{"class":91},[81,334,335],{"class":87},"all",[81,337,338],{"class":91},"();\n",[11,340,341,343,344,346,347,350],{},[18,342,66],{}," 是可以在直接在 ",[18,345,277],{}," 中使用的，不需要像 content v2中一样导入一个 \b",[18,348,349],{},"serverQueryContent"," 。",[11,352,353,354,357],{},"而且 ",[18,355,356],{},"#content/server"," 这个导入方式在 v3 也不能用了",[11,359,360,361],{},"使用时，会产生错误的类型提示，目前只能忽略它（不影响使用）。 可以查看 ",[362,363,367],"a",{"href":364,"rel":365},"https://github.com/nuxt/content/issues/2968#issuecomment-2589359589",[366],"nofollow","issue#2968",[46,369,371],{"id":370},"添加-feedxml","添加 feed.xml",[11,373,374],{},"找一个博客，点击他的订阅按钮，可以看到就是跳到一个 xml 页面上。",[11,376,377,378,381,382,385],{},"所以我们也只需要实现一个 ",[18,379,380],{},"feed.xml"," 即可，当然，一个网站也可以有多个 ",[18,383,384],{},"rss"," 订阅源",[11,387,388,389,393,394],{},"新建 ",[390,391,392],"strong",{},"\bserver/routes/feed.xml.ts"," ，因为是基于文件路径的路由，所以此路由就对应 ",[18,395,396],{},"$baseUrl/feed.xml",[11,398,399],{},"在RSS阅读器上，也是通过输入这个地址来实现订阅。",[46,401,402],{"id":402},"添加相关依赖",[404,405,406,409,412,415,418,421,424,427,430,433,436,439,442],"ul",{},[407,408,384],"li",{},[407,410,411],{},"unified",[407,413,414],{},"remark-parse",[407,416,417],{},"remark-gfm",[407,419,420],{},"remark-breaks",[407,422,423],{},"remark-frontmatter",[407,425,426],{},"remark-directive",[407,428,429],{},"remark-directive-rehype",[407,431,432],{},"remark-rehype",[407,434,435],{},"rehype-sanitize",[407,437,438],{},"rehype-autolink-headings",[407,440,441],{},"rehype-stringify",[407,443,444],{},"hast-util-to-html",[72,446,450],{"className":447,"code":448,"language":449,"meta":77,"style":77},"language-shell shiki shiki-themes github-light","npm i rss unified remark-parse remark-gfm remark-breaks remark-frontmatter remark-directive remark-directive-rehype remark-rehype rehype-sanitize rehype-autolink-headings rehype-stringify hast-util-to-html\n","shell",[18,451,452],{"__ignoreMap":77},[81,453,454,457,460,463,466,469,472,475,478,481,484,487,490,493,496],{"class":83,"line":84},[81,455,456],{"class":87},"npm",[81,458,459],{"class":107}," i",[81,461,462],{"class":107}," rss",[81,464,465],{"class":107}," unified",[81,467,468],{"class":107}," remark-parse",[81,470,471],{"class":107}," remark-gfm",[81,473,474],{"class":107}," remark-breaks",[81,476,477],{"class":107}," remark-frontmatter",[81,479,480],{"class":107}," remark-directive",[81,482,483],{"class":107}," remark-directive-rehype",[81,485,486],{"class":107}," remark-rehype",[81,488,489],{"class":107}," rehype-sanitize",[81,491,492],{"class":107}," rehype-autolink-headings",[81,494,495],{"class":107}," rehype-stringify",[81,497,498],{"class":107}," hast-util-to-html\n",[11,500,501],{},"这些插件是围绕 markdown 和 html 的解析/转换相关的插件。",[11,503,504,505,510,511,516],{},"如果你想了解他们之间是如何运作的，可以去看 ",[362,506,509],{"href":507,"rel":508},"https://diygod.cc/unified-markdown",[366],"DIYgod"," 的这篇文章。 以及这些插件在 ",[362,512,515],{"href":513,"rel":514},"https://github.com/Crossbell-Box/xLog/blob/dev/src/markdown/index.ts",[366],"xLog"," 中的具体用法。",[11,518,519],{},"我再挨个罗列出来介绍一遍，画个图，感觉没什么必要了，都是非常稳定且已经是事实上的标准的插件。",[11,521,522,523,526,527,530,531,530,534,530,537,540],{},"实际上 ",[18,524,525],{},"nuxt/mdc"," 就是使用了一系列 ",[18,528,529],{},"mdast","、",[18,532,533],{},"hast",[18,535,536],{},"remark",[18,538,539],{},"rehype"," 的插件 ，但是可惜的是它没有开放出对应的接口。",[11,542,543],{},"不然就不需要下载这么多插件了。",[46,545,546],{"id":546},"实现逻辑",[11,548,549],{},"直接放代码（忽略引入了，太长）：",[11,551,552],{},[390,553,554],{},"/server/routes/feed.xml.ts",[72,556,558],{"className":74,"code":557,"language":76,"meta":77,"style":77},"export default defineEventHandler(async (event) => {\n\n  const config = useRuntimeConfig()\n  // @ts-ignore\n  const posts: any = await queryCollection(event, 'content').order('date', \"DESC\").all();\n  const feed = new RSS({\n    title: '早早集市',\n    site_url: config.baseURL,\n    feed_url: config.baseURL + '/feed.xml',\n  })\n\n  for ( const post of posts) {\n    const content = post.rawbody\n    if (content) {\n      const markdownContent = cleanInvalidChars(content);\n      feed.item({\n        title: post.title,\n        url: `${config.baseURL}/${post.path}`,\n        date: post.date,\n        description: post.description,\n        custom_elements: [\n          {\n            'content:encoded': renderPageContent(markdownContent)\n          }\n        ]\n      })\n    }\n  }\n\n  const feedString = feed.xml();\n\n  setResponseHeader(event, 'Content-Type', 'text/xml')\n\n  return feedString\n\n})\n",[18,559,560,592,598,612,617,657,674,684,689,702,707,711,731,744,752,768,778,783,817,823,829,835,841,855,861,867,873,879,885,890,908,913,932,937,946,951],{"__ignoreMap":77},[81,561,562,565,568,571,573,576,579,583,586,589],{"class":83,"line":84},[81,563,564],{"class":294},"export",[81,566,567],{"class":294}," default",[81,569,570],{"class":87}," defineEventHandler",[81,572,322],{"class":91},[81,574,575],{"class":294},"async",[81,577,578],{"class":91}," (",[81,580,582],{"class":581},"sqxcx","event",[81,584,585],{"class":91},") ",[81,587,588],{"class":294},"=>",[81,590,591],{"class":91}," {\n",[81,593,594],{"class":83,"line":101},[81,595,597],{"emptyLinePlaceholder":596},true,"\n",[81,599,600,602,605,607,610],{"class":83,"line":114},[81,601,295],{"class":294},[81,603,604],{"class":177}," config",[81,606,301],{"class":294},[81,608,609],{"class":87}," useRuntimeConfig",[81,611,260],{"class":91},[81,613,614],{"class":83,"line":120},[81,615,616],{"class":288},"  // @ts-ignore\n",[81,618,619,621,623,626,629,631,633,635,637,639,641,643,645,647,649,651,653,655],{"class":83,"line":131},[81,620,295],{"class":294},[81,622,298],{"class":177},[81,624,625],{"class":294},":",[81,627,628],{"class":177}," any",[81,630,301],{"class":294},[81,632,304],{"class":294},[81,634,307],{"class":87},[81,636,310],{"class":91},[81,638,313],{"class":107},[81,640,316],{"class":91},[81,642,319],{"class":87},[81,644,322],{"class":91},[81,646,325],{"class":107},[81,648,140],{"class":91},[81,650,330],{"class":107},[81,652,316],{"class":91},[81,654,335],{"class":87},[81,656,338],{"class":91},[81,658,659,661,664,666,669,672],{"class":83,"line":149},[81,660,295],{"class":294},[81,662,663],{"class":177}," feed",[81,665,301],{"class":294},[81,667,668],{"class":294}," new",[81,670,671],{"class":87}," RSS",[81,673,98],{"class":91},[81,675,676,679,682],{"class":83,"line":160},[81,677,678],{"class":91},"    title: ",[81,680,681],{"class":107},"'早早集市'",[81,683,111],{"class":91},[81,685,686],{"class":83,"line":171},[81,687,688],{"class":91},"    site_url: config.baseURL,\n",[81,690,691,694,697,700],{"class":83,"line":181},[81,692,693],{"class":91},"    feed_url: config.baseURL ",[81,695,696],{"class":294},"+",[81,698,699],{"class":107}," '/feed.xml'",[81,701,111],{"class":91},[81,703,704],{"class":83,"line":187},[81,705,706],{"class":91},"  })\n",[81,708,709],{"class":83,"line":198},[81,710,597],{"emptyLinePlaceholder":596},[81,712,713,716,719,722,725,728],{"class":83,"line":210},[81,714,715],{"class":294},"  for",[81,717,718],{"class":91}," ( ",[81,720,721],{"class":294},"const",[81,723,724],{"class":177}," post",[81,726,727],{"class":294}," of",[81,729,730],{"class":91}," posts) {\n",[81,732,733,736,739,741],{"class":83,"line":220},[81,734,735],{"class":294},"    const",[81,737,738],{"class":177}," content",[81,740,301],{"class":294},[81,742,743],{"class":91}," post.rawbody\n",[81,745,746,749],{"class":83,"line":238},[81,747,748],{"class":294},"    if",[81,750,751],{"class":91}," (content) {\n",[81,753,754,757,760,762,765],{"class":83,"line":252},[81,755,756],{"class":294},"      const",[81,758,759],{"class":177}," markdownContent",[81,761,301],{"class":294},[81,763,764],{"class":87}," cleanInvalidChars",[81,766,767],{"class":91},"(content);\n",[81,769,770,773,776],{"class":83,"line":263},[81,771,772],{"class":91},"      feed.",[81,774,775],{"class":87},"item",[81,777,98],{"class":91},[81,779,780],{"class":83,"line":269},[81,781,782],{"class":91},"        title: post.title,\n",[81,784,786,789,792,795,798,801,804,807,809,812,815],{"class":83,"line":785},18,[81,787,788],{"class":91},"        url: ",[81,790,791],{"class":107},"`${",[81,793,794],{"class":91},"config",[81,796,797],{"class":107},".",[81,799,800],{"class":91},"baseURL",[81,802,803],{"class":107},"}/${",[81,805,806],{"class":91},"post",[81,808,797],{"class":107},[81,810,811],{"class":91},"path",[81,813,814],{"class":107},"}`",[81,816,111],{"class":91},[81,818,820],{"class":83,"line":819},19,[81,821,822],{"class":91},"        date: post.date,\n",[81,824,826],{"class":83,"line":825},20,[81,827,828],{"class":91},"        description: post.description,\n",[81,830,832],{"class":83,"line":831},21,[81,833,834],{"class":91},"        custom_elements: [\n",[81,836,838],{"class":83,"line":837},22,[81,839,840],{"class":91},"          {\n",[81,842,844,847,849,852],{"class":83,"line":843},23,[81,845,846],{"class":107},"            'content:encoded'",[81,848,92],{"class":91},[81,850,851],{"class":87},"renderPageContent",[81,853,854],{"class":91},"(markdownContent)\n",[81,856,858],{"class":83,"line":857},24,[81,859,860],{"class":91},"          }\n",[81,862,864],{"class":83,"line":863},25,[81,865,866],{"class":91},"        ]\n",[81,868,870],{"class":83,"line":869},26,[81,871,872],{"class":91},"      })\n",[81,874,876],{"class":83,"line":875},27,[81,877,878],{"class":91},"    }\n",[81,880,882],{"class":83,"line":881},28,[81,883,884],{"class":91},"  }\n",[81,886,888],{"class":83,"line":887},29,[81,889,597],{"emptyLinePlaceholder":596},[81,891,893,895,898,900,903,906],{"class":83,"line":892},30,[81,894,295],{"class":294},[81,896,897],{"class":177}," feedString",[81,899,301],{"class":294},[81,901,902],{"class":91}," feed.",[81,904,905],{"class":87},"xml",[81,907,338],{"class":91},[81,909,911],{"class":83,"line":910},31,[81,912,597],{"emptyLinePlaceholder":596},[81,914,916,919,921,924,926,929],{"class":83,"line":915},32,[81,917,918],{"class":87},"  setResponseHeader",[81,920,310],{"class":91},[81,922,923],{"class":107},"'Content-Type'",[81,925,140],{"class":91},[81,927,928],{"class":107},"'text/xml'",[81,930,931],{"class":91},")\n",[81,933,935],{"class":83,"line":934},33,[81,936,597],{"emptyLinePlaceholder":596},[81,938,940,943],{"class":83,"line":939},34,[81,941,942],{"class":294},"  return",[81,944,945],{"class":91}," feedString\n",[81,947,949],{"class":83,"line":948},35,[81,950,597],{"emptyLinePlaceholder":596},[81,952,954],{"class":83,"line":953},36,[81,955,956],{"class":91},"})\n",[11,958,959],{},"先来说明一下每一段主要逻辑",[11,961,962,965,966,969],{},[18,963,964],{},"const config = useRuntimeConfig()"," 需要你在 ",[18,967,968],{},"nuxt.config.ts"," 中配置如下信息：",[72,971,973],{"className":74,"code":972,"language":76,"meta":77,"style":77},"runtimeConfig: {\n    baseURL: 'your url' // 或者使用环境变量覆盖\n}\n",[18,974,975,983,996],{"__ignoreMap":77},[81,976,977,980],{"class":83,"line":84},[81,978,979],{"class":87},"runtimeConfig",[81,981,982],{"class":91},": {\n",[81,984,985,988,990,993],{"class":83,"line":101},[81,986,987],{"class":87},"    baseURL",[81,989,92],{"class":91},[81,991,992],{"class":107},"'your url'",[81,994,995],{"class":288}," // 或者使用环境变量覆盖\n",[81,997,998],{"class":83,"line":114},[81,999,1000],{"class":91},"}\n",[11,1002,1003,1004,1006],{},"使用 ",[18,1005,66],{}," 获取到所有原始的 md 内容",[11,1008,1009,1010,1013,1014],{},"我们的目的就是把每一篇文章都生成一个 ",[18,1011,1012],{},"feed item","， 所以循环所有文章，调用 ",[18,1015,1016],{},"feed.item()",[11,1018,1019,1020,1023,1024],{},"此时出现了第一个问题： md原内容并不是等同于直接读取md文件，存在数据库中的原内容已经使",[18,1021,1022],{},"\\n"," 变为了 ",[18,1025,1026],{},"\\\\n",[11,1028,1029],{},"所以为了使md能被正常解析，需要先清除一下没用的字符",[11,1031,1032],{},"同文件下：",[72,1034,1036],{"className":74,"code":1035,"language":76,"meta":77,"style":77},"function cleanInvalidChars(content:string) {\n  return content.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '').replace(/\\\\n/g, '\\n').trim();\n}\n",[18,1037,1038,1056,1117],{"__ignoreMap":77},[81,1039,1040,1043,1045,1047,1049,1051,1053],{"class":83,"line":84},[81,1041,1042],{"class":294},"function",[81,1044,764],{"class":87},[81,1046,322],{"class":91},[81,1048,88],{"class":581},[81,1050,625],{"class":294},[81,1052,232],{"class":177},[81,1054,1055],{"class":91},") {\n",[81,1057,1058,1060,1063,1066,1068,1071,1074,1076,1079,1081,1084,1086,1088,1090,1092,1096,1099,1101,1103,1106,1108,1110,1112,1115],{"class":83,"line":101},[81,1059,942],{"class":294},[81,1061,1062],{"class":91}," content.",[81,1064,1065],{"class":87},"replace",[81,1067,322],{"class":91},[81,1069,1070],{"class":107},"/",[81,1072,1073],{"class":177},"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]",[81,1075,1070],{"class":107},[81,1077,1078],{"class":294},"g",[81,1080,140],{"class":91},[81,1082,1083],{"class":107},"''",[81,1085,316],{"class":91},[81,1087,1065],{"class":87},[81,1089,322],{"class":91},[81,1091,1070],{"class":107},[81,1093,1095],{"class":1094},"s691h","\\\\",[81,1097,1098],{"class":107},"n/",[81,1100,1078],{"class":294},[81,1102,140],{"class":91},[81,1104,1105],{"class":107},"'",[81,1107,1022],{"class":177},[81,1109,1105],{"class":107},[81,1111,316],{"class":91},[81,1113,1114],{"class":87},"trim",[81,1116,338],{"class":91},[81,1118,1119],{"class":83,"line":114},[81,1120,1000],{"class":91},[11,1122,1123,1124,1127],{},"处理好后，如果不写 ",[18,1125,1126],{},"custom_elements"," ，这个 feed 也已经有效了，但缺点就是无法在 RSS 阅读器中直接阅读文章内容。",[11,1129,1130,1131,1133,1134,1137],{},"所以刚才一堆插件，就是为了解析 ",[18,1132,1126],{}," 里要塞入的 ",[18,1135,1136],{},"html"," 字符串",[11,1139,1032],{},[72,1141,1143],{"className":74,"code":1142,"language":76,"meta":77,"style":77},"function renderPageContent(content: string) {\n  const pipeline = unified()\n  .use(remarkParse)\n  .use(remarkBreaks)\n  .use(remarkFrontmatter, [\"yaml\"])\n  .use(remarkGfm, {  singleTilde: false })\n  .use(remarkDirective)\n  .use(remarkDirectiveRehype)\n  .use(remarkRehype)\n  .use(rehypeSanitize)\n  .use(rehypeAutolinkHeadings)\n  .use(rehypeStringify)\n\n  const mdastTree = pipeline.parse(content)\n  const hastTree = pipeline.runSync(mdastTree, content)\n  return toHtml(hastTree)\n}\n",[18,1144,1145,1163,1176,1187,1196,1211,1226,1235,1244,1253,1262,1271,1280,1284,1302,1319,1329],{"__ignoreMap":77},[81,1146,1147,1149,1152,1154,1156,1158,1161],{"class":83,"line":84},[81,1148,1042],{"class":294},[81,1150,1151],{"class":87}," renderPageContent",[81,1153,322],{"class":91},[81,1155,88],{"class":581},[81,1157,625],{"class":294},[81,1159,1160],{"class":177}," string",[81,1162,1055],{"class":91},[81,1164,1165,1167,1170,1172,1174],{"class":83,"line":101},[81,1166,295],{"class":294},[81,1168,1169],{"class":177}," pipeline",[81,1171,301],{"class":294},[81,1173,465],{"class":87},[81,1175,260],{"class":91},[81,1177,1178,1181,1184],{"class":83,"line":114},[81,1179,1180],{"class":91},"  .",[81,1182,1183],{"class":87},"use",[81,1185,1186],{"class":91},"(remarkParse)\n",[81,1188,1189,1191,1193],{"class":83,"line":120},[81,1190,1180],{"class":91},[81,1192,1183],{"class":87},[81,1194,1195],{"class":91},"(remarkBreaks)\n",[81,1197,1198,1200,1202,1205,1208],{"class":83,"line":131},[81,1199,1180],{"class":91},[81,1201,1183],{"class":87},[81,1203,1204],{"class":91},"(remarkFrontmatter, [",[81,1206,1207],{"class":107},"\"yaml\"",[81,1209,1210],{"class":91},"])\n",[81,1212,1213,1215,1217,1220,1223],{"class":83,"line":149},[81,1214,1180],{"class":91},[81,1216,1183],{"class":87},[81,1218,1219],{"class":91},"(remarkGfm, {  singleTilde: ",[81,1221,1222],{"class":177},"false",[81,1224,1225],{"class":91}," })\n",[81,1227,1228,1230,1232],{"class":83,"line":160},[81,1229,1180],{"class":91},[81,1231,1183],{"class":87},[81,1233,1234],{"class":91},"(remarkDirective)\n",[81,1236,1237,1239,1241],{"class":83,"line":171},[81,1238,1180],{"class":91},[81,1240,1183],{"class":87},[81,1242,1243],{"class":91},"(remarkDirectiveRehype)\n",[81,1245,1246,1248,1250],{"class":83,"line":181},[81,1247,1180],{"class":91},[81,1249,1183],{"class":87},[81,1251,1252],{"class":91},"(remarkRehype)\n",[81,1254,1255,1257,1259],{"class":83,"line":187},[81,1256,1180],{"class":91},[81,1258,1183],{"class":87},[81,1260,1261],{"class":91},"(rehypeSanitize)\n",[81,1263,1264,1266,1268],{"class":83,"line":198},[81,1265,1180],{"class":91},[81,1267,1183],{"class":87},[81,1269,1270],{"class":91},"(rehypeAutolinkHeadings)\n",[81,1272,1273,1275,1277],{"class":83,"line":210},[81,1274,1180],{"class":91},[81,1276,1183],{"class":87},[81,1278,1279],{"class":91},"(rehypeStringify)\n",[81,1281,1282],{"class":83,"line":220},[81,1283,597],{"emptyLinePlaceholder":596},[81,1285,1286,1288,1291,1293,1296,1299],{"class":83,"line":238},[81,1287,295],{"class":294},[81,1289,1290],{"class":177}," mdastTree",[81,1292,301],{"class":294},[81,1294,1295],{"class":91}," pipeline.",[81,1297,1298],{"class":87},"parse",[81,1300,1301],{"class":91},"(content)\n",[81,1303,1304,1306,1309,1311,1313,1316],{"class":83,"line":252},[81,1305,295],{"class":294},[81,1307,1308],{"class":177}," hastTree",[81,1310,301],{"class":294},[81,1312,1295],{"class":91},[81,1314,1315],{"class":87},"runSync",[81,1317,1318],{"class":91},"(mdastTree, content)\n",[81,1320,1321,1323,1326],{"class":83,"line":263},[81,1322,942],{"class":294},[81,1324,1325],{"class":87}," toHtml",[81,1327,1328],{"class":91},"(hastTree)\n",[81,1330,1331],{"class":83,"line":269},[81,1332,1000],{"class":91},[11,1334,1335,1336,1339,1340,1342,1343,1345],{},"此时，你可以在你的 \b",[18,1337,1338],{},"RSS"," 阅读器中订阅自己的博客，然后看看是否能展示正常的文章内容了，或者在发布之前，先在浏览器打开 ",[18,1341,380],{}," ，观察 ",[18,1344,905],{}," 内的文章内容渲染是否正确。",[11,1347,1348],{},[1349,1350,1351],"em",{},"使用插件算是比较简单的实现方式了，搭配 AI 来手动写函数处理的话也是浪费了我不少时间，最后还有很多兼容问题，头铁的朋友可以试试",[46,1353,1354],{"id":1354},"最后",[11,1356,1357,1358,1362],{},"欢迎订阅我的博客：",[362,1359,1338],{"href":1360,"rel":1361},"https://blog.zzao.club/feed.xml",[366]," ，为你带来最新的 Nuxt 实战内容",[11,1364,1365,1366],{},"链接: ",[362,1367,1360],{"href":1360,"rel":1368},[366],[1370,1371],"hr",{},[11,1373,1374],{},"插件库会随着时间逐步完善，所以本文同样具有时效性。但无论如何，文章开头的版本号代表本文的生效范围。",[11,1376,1377],{},"欢迎在评论区留下你的疑问和高见~",[1379,1380,1381],"style",{},"html pre.shiki code .s7eDp, html code.shiki .s7eDp{--shiki-default:#6F42C1}html pre.shiki code .sgsFI, html code.shiki .sgsFI{--shiki-default:#24292E}html pre.shiki code .sYBdl, html code.shiki .sYBdl{--shiki-default:#032F62}html pre.shiki code .sYu0t, html code.shiki .sYu0t{--shiki-default:#005CC5}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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sD7c4, html code.shiki .sD7c4{--shiki-default:#D73A49}html pre.shiki code .sqxcx, html code.shiki .sqxcx{--shiki-default:#E36209}html pre.shiki code .s691h, html code.shiki .s691h{--shiki-default:#22863A;--shiki-default-font-weight:bold}",{"title":77,"searchDepth":101,"depth":101,"links":1383},[1384,1385,1386,1387,1388],{"id":48,"depth":101,"text":48},{"id":370,"depth":101,"text":371},{"id":402,"depth":101,"text":402},{"id":546,"depth":101,"text":546},{"id":1354,"depth":101,"text":1354},"2025-01-15T00:00:00.000Z","md","2025-08-19T00:00:00.000Z",{},"/post/nuxt/nuxt-content-v3-rss-done","---\ntitle: Nuxt Content v3 实现 RSS 订阅功能\ndate: 2025-01-15\nlastmod: 2025-08-19\ntags: [\"Nuxt\"]\nversions: [\"@nuxt/content@3.0.0-alpha.8\", \"@nuxtjs/mdc@0.12.1\"]\ndescription: Nuxt Content v3 实现 RSS 订阅功能\n---\n这可能是对现在而言唯一一篇关于Content v3 版本的 RSS 订阅的文章了。我找遍了能搜出来的每篇文章，没有一个能用的。\n\n因为 `Nuxt Content` v3 还没发布正式版，其相关生态的 `module` 都没支持最新的 `Content` , 感觉不是很复杂的功能，但是就不跟  `content v3` 一起出个 `alpha` 版，很无语。\n\n本文来带大家在 `Nuxt Content v3` 里实现 RSS 订阅功能。\n\n## 添加原始内容\n\n在 `content.config.ts` 中 `rawbody` 是一个特殊的 `schema` ，配置后，将会把md原始内容存起来，在使用 `queryCollection` 时，就可以查到 `rawbody` 了\n\n```typescript\ncontent: defineCollection({\n    type: 'page',\n    source: {\n      include: '**/*.md',\n      exclude: ['**/-*.md', 'book/**/*.md'],\n      prefix: '/post',\n      repository: 'https://github.com/aatrooox/xxxx',\n      authToken: process.env.CONTENT_REPO_TOKEN\n    },\n    schema: z.object({\n      date: z.date(),\n      lastmod: z.date(),\n      tags: z.array(z.string()),\n      versions: z.array(z.string()),\n      rawbody: z.string()\n    })\n  }),\n```\n\n在 `server` 中查询时：\n\n```typescript\n// @ts-ignore\n  const posts = await queryCollection(event, 'content').order('date', \"DESC\").all();\n```\n\n`queryCollection` 是可以在直接在 `server` 中使用的，不需要像 content v2中一样导入一个 \b`serverQueryContent` 。\n\n而且 `#content/server` 这个导入方式在 v3 也不能用了\n\n使用时，会产生错误的类型提示，目前只能忽略它（不影响使用）。 可以查看 [issue#2968](https://github.com/nuxt/content/issues/2968#issuecomment-2589359589) \n## 添加 feed.xml\n\n找一个博客，点击他的订阅按钮，可以看到就是跳到一个 xml 页面上。\n\n所以我们也只需要实现一个 `feed.xml` 即可，当然，一个网站也可以有多个 `rss` 订阅源\n\n新建 **\bserver/routes/feed.xml.ts** ，因为是基于文件路径的路由，所以此路由就对应 `$baseUrl/feed.xml`\n\n在RSS阅读器上，也是通过输入这个地址来实现订阅。\n\n## 添加相关依赖\n\n- rss\n- unified\n- remark-parse\n- remark-gfm\n- remark-breaks\n- remark-frontmatter\n- remark-directive\n- remark-directive-rehype\n- remark-rehype\n- rehype-sanitize\n- rehype-autolink-headings\n- rehype-stringify\n- hast-util-to-html\n\n```shell\nnpm i rss unified remark-parse remark-gfm remark-breaks remark-frontmatter remark-directive remark-directive-rehype remark-rehype rehype-sanitize rehype-autolink-headings rehype-stringify hast-util-to-html\n```\n\n这些插件是围绕 markdown 和 html 的解析/转换相关的插件。\n\n如果你想了解他们之间是如何运作的，可以去看 [DIYgod](https://diygod.cc/unified-markdown) 的这篇文章。 以及这些插件在 [xLog](https://github.com/Crossbell-Box/xLog/blob/dev/src/markdown/index.ts) 中的具体用法。\n\n我再挨个罗列出来介绍一遍，画个图，感觉没什么必要了，都是非常稳定且已经是事实上的标准的插件。\n\n实际上 `nuxt/mdc` 就是使用了一系列 `mdast`、`hast`、`remark`、`rehype` 的插件 ，但是可惜的是它没有开放出对应的接口。 \n\n不然就不需要下载这么多插件了。\n\n## 实现逻辑\n\n直接放代码（忽略引入了，太长）：\n\n**/server/routes/feed.xml.ts**\n\n```typescript\nexport default defineEventHandler(async (event) => {\n\n  const config = useRuntimeConfig()\n  // @ts-ignore\n  const posts: any = await queryCollection(event, 'content').order('date', \"DESC\").all();\n  const feed = new RSS({\n    title: '早早集市',\n    site_url: config.baseURL,\n    feed_url: config.baseURL + '/feed.xml',\n  })\n\n  for ( const post of posts) {\n    const content = post.rawbody\n    if (content) {\n      const markdownContent = cleanInvalidChars(content);\n      feed.item({\n        title: post.title,\n        url: `${config.baseURL}/${post.path}`,\n        date: post.date,\n        description: post.description,\n        custom_elements: [\n          {\n            'content:encoded': renderPageContent(markdownContent)\n          }\n        ]\n      })\n    }\n  }\n\n  const feedString = feed.xml();\n\n  setResponseHeader(event, 'Content-Type', 'text/xml')\n\n  return feedString\n\n})\n```\n\n先来说明一下每一段主要逻辑\n\n`const config = useRuntimeConfig()` 需要你在 `nuxt.config.ts` 中配置如下信息：\n\n```typescript\nruntimeConfig: {\n\tbaseURL: 'your url' // 或者使用环境变量覆盖\n}\n```\n\n使用 `queryCollection` 获取到所有原始的 md 内容\n\n我们的目的就是把每一篇文章都生成一个 `feed item`， 所以循环所有文章，调用 `feed.item()`\n\n此时出现了第一个问题： md原内容并不是等同于直接读取md文件，存在数据库中的原内容已经使`\\n` 变为了 `\\\\n` \n\n所以为了使md能被正常解析，需要先清除一下没用的字符\n\n同文件下：\n\n```typescript\nfunction cleanInvalidChars(content:string) {\n  return content.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '').replace(/\\\\n/g, '\\n').trim();\n}\n```\n\n处理好后，如果不写 `custom_elements` ，这个 feed 也已经有效了，但缺点就是无法在 RSS 阅读器中直接阅读文章内容。\n\n所以刚才一堆插件，就是为了解析 `custom_elements` 里要塞入的 `html` 字符串\n\n同文件下：\n\n```typescript\nfunction renderPageContent(content: string) {\n  const pipeline = unified()\n  .use(remarkParse)\n  .use(remarkBreaks)\n  .use(remarkFrontmatter, [\"yaml\"])\n  .use(remarkGfm, {  singleTilde: false })\n  .use(remarkDirective)\n  .use(remarkDirectiveRehype)\n  .use(remarkRehype)\n  .use(rehypeSanitize)\n  .use(rehypeAutolinkHeadings)\n  .use(rehypeStringify)\n\n  const mdastTree = pipeline.parse(content)\n  const hastTree = pipeline.runSync(mdastTree, content)\n  return toHtml(hastTree)\n}\n```\n\n此时，你可以在你的 \b`RSS` 阅读器中订阅自己的博客，然后看看是否能展示正常的文章内容了，或者在发布之前，先在浏览器打开 `feed.xml` ，观察 `xml` 内的文章内容渲染是否正确。\n\n_使用插件算是比较简单的实现方式了，搭配 AI 来手动写函数处理的话也是浪费了我不少时间，最后还有很多兼容问题，头铁的朋友可以试试_\n## 最后\n\n欢迎订阅我的博客：[RSS](https://blog.zzao.club/feed.xml) ，为你带来最新的 Nuxt 实战内容\n\n链接: https://blog.zzao.club/feed.xml\n\n---\n\n插件库会随着时间逐步完善，所以本文同样具有时效性。但无论如何，文章开头的版本号代表本文的生效范围。\n\n欢迎在评论区留下你的疑问和高见~\n",{"title":5,"description":5},"post/nuxt/nuxt-content-v3-rss-done",[1398],"Nuxt",[1400,1401],"@nuxt/content@3.0.0-alpha.8","@nuxtjs/mdc@0.12.1","P7-D1f5NW2XlZWeZBEwRa-pcR7jJkEY01gQCoh_NGkg",[1404,1408],{"title":1405,"path":1406,"stem":1407},"OpenClaw 安装入门（Windows）","/post/zzao/openclaw/openclaw-install-windows","post/zzao/openclaw/openclaw-install-windows",{"title":1409,"path":1410,"stem":1411},"假设你是AI，你的Skill应该是什么样的","/post/zzao/ai-skill-structure","post/zzao/ai-skill-structure",1779005086307]