@jant/core 0.6.14 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/bin/commands/import-site.js +81 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -1,31 +1,258 @@
1
- import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
1
+ import {
2
+ CONFIG_FIELDS,
3
+ type Bindings,
4
+ type ConfigEditorDefinition,
5
+ type ConfigEditorFieldState,
6
+ type ConfigEditorKey,
7
+ type ConfigEditorResettableKey,
8
+ type ConfigEditorVisibleKey,
9
+ type ConfigKey,
10
+ } from "../types.js";
11
+ import { getSupportedLocaleEntries } from "../i18n/supported-locales.js";
2
12
  import { SETTINGS_KEYS } from "./constants.js";
3
- import { normalizeTimeZone } from "./timezones.js";
13
+ import { getEnvString } from "./env.js";
14
+ import { normalizeEditableSettingValue } from "./schemas.js";
15
+ import { getTimeZoneOptions } from "./timezones.js";
4
16
 
5
17
  export const demoLockedSettingKeys = new Set<ConfigKey>(["NOINDEX"]);
6
18
 
7
- /** Config keys that can be modified via the settings API */
19
+ /** Config keys explicitly approved for runtime editing. */
8
20
  export const editableSettingKeys = Object.entries(CONFIG_FIELDS)
21
+ .filter(([, field]) => "editor" in field)
22
+ .map(([key]) => key as ConfigEditorKey);
23
+
24
+ /** Safe settings shown in Config Editor, including dedicated-page links. */
25
+ export const configEditorVisibleKeys = Object.entries(CONFIG_FIELDS)
26
+ .filter(([, field]) => "editor" in field || "configEditorLink" in field)
27
+ .map(([key]) => key as ConfigEditorVisibleKey);
28
+
29
+ /** Config Editor keys whose DB override can be safely removed directly. */
30
+ export const resettableConfigEditorKeys = Object.entries(CONFIG_FIELDS)
9
31
  .filter(
10
- ([, field]) => !field.envOnly && !("internal" in field && field.internal),
32
+ ([, field]) =>
33
+ "editor" in field ||
34
+ ("configEditorLink" in field &&
35
+ "resettable" in field.configEditorLink &&
36
+ field.configEditorLink.resettable === true),
11
37
  )
12
- .map(([key]) => key as ConfigKey);
38
+ .map(([key]) => key as ConfigEditorResettableKey);
39
+
40
+ export function isEditableSettingKey(key: string): key is ConfigEditorKey {
41
+ return editableSettingKeys.includes(key as ConfigEditorKey);
42
+ }
43
+
44
+ export function isResettableConfigEditorKey(
45
+ key: string,
46
+ ): key is ConfigEditorResettableKey {
47
+ return resettableConfigEditorKeys.includes(key as ConfigEditorResettableKey);
48
+ }
49
+
50
+ function tryNormalizeResolvedSettingValue(
51
+ key: ConfigEditorKey,
52
+ value: string,
53
+ ): string | undefined {
54
+ try {
55
+ return normalizeEditableSettingValue(key, value);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export function getEditableSettingFallbackValue(
62
+ key: ConfigEditorKey,
63
+ env?: Bindings,
64
+ allSettings: Record<string, string> = {},
65
+ ): string {
66
+ const field = CONFIG_FIELDS[key];
67
+ const envKeys = "envKeys" in field ? field.envKeys : undefined;
68
+ const envValue = env ? getEnvString(env, ...(envKeys ?? [])) : undefined;
69
+ if (envValue) {
70
+ const normalizedEnv = tryNormalizeResolvedSettingValue(key, envValue);
71
+ if (normalizedEnv !== undefined) return normalizedEnv;
72
+ }
73
+
74
+ const normalizedDefault = tryNormalizeResolvedSettingValue(
75
+ key,
76
+ field.defaultValue,
77
+ );
78
+ if (normalizedDefault !== undefined) return normalizedDefault;
79
+
80
+ if ("fallbackKey" in field && field.fallbackKey) {
81
+ return getEditableSettingValue(allSettings, field.fallbackKey, env);
82
+ }
83
+
84
+ throw new Error(`Missing valid Config Editor fallback for ${key}`);
85
+ }
13
86
 
14
87
  export function getEditableSettingValue(
15
88
  allSettings: Record<string, string>,
16
- key: ConfigKey,
89
+ key: ConfigEditorKey,
90
+ env?: Bindings,
17
91
  ): string {
18
- const value = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
19
- return key === SETTINGS_KEYS.TIME_ZONE ? normalizeTimeZone(value) : value;
92
+ const fallbackValue = getEditableSettingFallbackValue(key, env, allSettings);
93
+ const value = Object.hasOwn(allSettings, key)
94
+ ? allSettings[key]
95
+ : fallbackValue;
96
+ return (
97
+ tryNormalizeResolvedSettingValue(key, value ?? fallbackValue) ??
98
+ fallbackValue
99
+ );
100
+ }
101
+
102
+ function getEditorConstraints(
103
+ key: ConfigEditorKey,
104
+ definition: ConfigEditorDefinition,
105
+ value: string,
106
+ fallbackValue: string,
107
+ ): Pick<
108
+ ConfigEditorFieldState,
109
+ "maxLength" | "min" | "max" | "step" | "options"
110
+ > {
111
+ switch (definition.type) {
112
+ case "boolean":
113
+ return {};
114
+ case "string":
115
+ return { maxLength: definition.maxLength };
116
+ case "number":
117
+ return {
118
+ min: definition.min,
119
+ max: definition.max,
120
+ step: definition.step,
121
+ };
122
+ case "enum":
123
+ if (definition.options) return { options: definition.options };
124
+ if (definition.optionsSource === "contentLanguage") {
125
+ const options = getSupportedLocaleEntries().map((entry) => entry.tag);
126
+ for (const candidate of [fallbackValue, value]) {
127
+ if (candidate && !options.includes(candidate))
128
+ options.push(candidate);
129
+ }
130
+ return { options };
131
+ }
132
+ if (definition.optionsSource === "timeZone") {
133
+ const options = getTimeZoneOptions(value).map((entry) => entry.value);
134
+ for (const candidate of [fallbackValue, value]) {
135
+ if (candidate && !options.includes(candidate))
136
+ options.push(candidate);
137
+ }
138
+ return { options };
139
+ }
140
+ return { options: [value] };
141
+ }
142
+ }
143
+
144
+ function buildLinkedConfigEditorField(
145
+ allSettings: Record<string, string>,
146
+ env: Bindings,
147
+ key: ConfigEditorVisibleKey,
148
+ ): ConfigEditorFieldState {
149
+ const field = CONFIG_FIELDS[key];
150
+ if (!("configEditorLink" in field)) {
151
+ throw new Error(`Missing Config Editor link metadata for ${key}`);
152
+ }
153
+ const definition = field.configEditorLink;
154
+ const resettable =
155
+ "resettable" in definition && definition.resettable === true;
156
+ const modified = Object.hasOwn(allSettings, key);
157
+ const storedValue = allSettings[key] ?? "";
158
+
159
+ if (definition.display === "configured") {
160
+ const envKeys = "envKeys" in field ? field.envKeys : [];
161
+ const fallbackRawValue =
162
+ getEnvString(env, ...envKeys) || field.defaultValue;
163
+ const effectiveValue = modified ? storedValue : fallbackRawValue;
164
+ return {
165
+ key,
166
+ mode: "link",
167
+ type: definition.type,
168
+ value: effectiveValue.trim() ? "true" : "false",
169
+ fallbackValue: fallbackRawValue.trim() ? "true" : "false",
170
+ modified,
171
+ locked: false,
172
+ settingsPath: definition.settingsPath,
173
+ display: definition.display,
174
+ ...(resettable && { resettable: true }),
175
+ };
176
+ }
177
+
178
+ const fieldEnvKeys = "envKeys" in field ? field.envKeys : [];
179
+ let fallbackValue: string =
180
+ ("fallbackValue" in definition ? definition.fallbackValue : undefined) ??
181
+ getEnvString(env, ...fieldEnvKeys) ??
182
+ field.defaultValue;
183
+ if ("fallbackKey" in definition && definition.fallbackKey) {
184
+ const fallbackField = CONFIG_FIELDS[definition.fallbackKey];
185
+ const envKeys = "envKeys" in fallbackField ? fallbackField.envKeys : [];
186
+ fallbackValue = getEnvString(env, ...envKeys) || fallbackField.defaultValue;
187
+ }
188
+ const value = storedValue.trim() || fallbackValue;
189
+
190
+ return {
191
+ key,
192
+ mode: "link",
193
+ type: definition.type,
194
+ value: definition.type === "boolean" ? String(value === "true") : value,
195
+ fallbackValue,
196
+ modified,
197
+ locked: false,
198
+ settingsPath: definition.settingsPath,
199
+ display: definition.display,
200
+ ...(resettable && { resettable: true }),
201
+ };
202
+ }
203
+
204
+ export function buildConfigEditorFields(
205
+ allSettings: Record<string, string>,
206
+ env: Bindings,
207
+ demoMode: boolean,
208
+ ): ConfigEditorFieldState[] {
209
+ return configEditorVisibleKeys.map((key) => {
210
+ if ("configEditorLink" in CONFIG_FIELDS[key]) {
211
+ return buildLinkedConfigEditorField(allSettings, env, key);
212
+ }
213
+
214
+ if (!isEditableSettingKey(key)) {
215
+ throw new Error(`Missing Config Editor metadata for ${key}`);
216
+ }
217
+
218
+ const definition: ConfigEditorDefinition = CONFIG_FIELDS[key].editor;
219
+ const fallbackValue = getEditableSettingFallbackValue(
220
+ key,
221
+ env,
222
+ allSettings,
223
+ );
224
+ const value =
225
+ demoMode && key === SETTINGS_KEYS.NOINDEX
226
+ ? "true"
227
+ : getEditableSettingValue(allSettings, key, env);
228
+ const fallbackKey = (CONFIG_FIELDS[key] as { fallbackKey?: "PAGE_SIZE" })
229
+ .fallbackKey;
230
+ const base = {
231
+ key,
232
+ mode: "edit" as const,
233
+ type: definition.type,
234
+ value,
235
+ fallbackValue,
236
+ modified: Object.hasOwn(allSettings, key),
237
+ locked: demoMode && demoLockedSettingKeys.has(key),
238
+ ...(fallbackKey ? { fallbackKey } : {}),
239
+ };
240
+
241
+ return {
242
+ ...base,
243
+ ...getEditorConstraints(key, definition, value, fallbackValue),
244
+ };
245
+ });
20
246
  }
21
247
 
22
248
  export function buildEditableSettingsResponse(
23
249
  allSettings: Record<string, string>,
24
250
  demoMode: boolean,
251
+ env?: Bindings,
25
252
  ): Record<string, string> {
26
253
  const result: Record<string, string> = {};
27
254
  for (const key of editableSettingKeys) {
28
- result[key] = getEditableSettingValue(allSettings, key);
255
+ result[key] = getEditableSettingValue(allSettings, key, env);
29
256
  }
30
257
  if (demoMode) {
31
258
  result.NOINDEX = "true";
@@ -38,14 +265,14 @@ export function partitionEditableSettingUpdates(
38
265
  updates: Record<string, string>,
39
266
  demoMode: boolean,
40
267
  ): {
41
- filteredUpdates: Partial<Record<ConfigKey, string>>;
268
+ filteredUpdates: Partial<Record<ConfigEditorKey, string>>;
42
269
  rejectedKeys: string[];
43
270
  } {
44
- const filteredUpdates: Partial<Record<ConfigKey, string>> = {};
271
+ const filteredUpdates: Partial<Record<ConfigEditorKey, string>> = {};
45
272
  const rejectedKeys: string[] = [];
46
273
 
47
274
  for (const [key, value] of Object.entries(updates)) {
48
- const configKey = key as ConfigKey;
275
+ const configKey = key as ConfigEditorKey;
49
276
 
50
277
  if (
51
278
  demoMode &&
@@ -18,9 +18,9 @@ export function isRatingSortOrder(
18
18
  }
19
19
 
20
20
  /**
21
- * Returns true when a collection has enough rated posts to make rating sort useful.
21
+ * Returns true when a collection has enough rated Threads to make rating sort useful.
22
22
  *
23
- * @param ratedPostCount - Number of posts in the collection with a rating
23
+ * @param ratedThreadCount - Number of Threads with at least one rating
24
24
  * @returns Whether rating sort should be shown to readers
25
25
  *
26
26
  * @example
@@ -28,13 +28,15 @@ export function isRatingSortOrder(
28
28
  * supportsCollectionRatingSort(2);
29
29
  * ```
30
30
  */
31
- export function supportsCollectionRatingSort(ratedPostCount: number): boolean {
32
- return ratedPostCount > 1;
31
+ export function supportsCollectionRatingSort(
32
+ ratedThreadCount: number,
33
+ ): boolean {
34
+ return ratedThreadCount > 1;
33
35
  }
34
36
 
35
37
  /**
36
38
  * Resolves the sort order for a collection page, falling back when rating
37
- * sorting is requested but the collection does not have enough rated posts.
39
+ * sorting is requested but the collection does not have enough rated Threads.
38
40
  *
39
41
  * @param requestedSort - Sort order from the request query
40
42
  * @param defaultSort - Collection default sort order
@@ -22,12 +22,14 @@ export const RESERVED_PATHS = [
22
22
  "reset",
23
23
  "collections",
24
24
  "compose",
25
+ "preview",
25
26
  "new",
26
27
  "static",
27
28
  "assets",
28
29
  "_assets",
29
30
  "healthz",
30
31
  "readyz",
32
+ "skill.md",
31
33
  ] as const;
32
34
 
33
35
  export type ReservedPath = (typeof RESERVED_PATHS)[number];
@@ -0,0 +1,25 @@
1
+ const NON_FEED_PATH_PREFIXES = ["/api", "/settings", "/compose", "/_"];
2
+
3
+ function hasPathPrefix(path: string, prefix: string): boolean {
4
+ return path === prefix || path.startsWith(`${prefix}/`);
5
+ }
6
+
7
+ /**
8
+ * Check whether an internal application path is an Atom feed endpoint.
9
+ *
10
+ * @param path - Request pathname after any configured site prefix is removed
11
+ * @returns Whether the path is a canonical or legacy feed URL
12
+ * @example
13
+ * ```ts
14
+ * isRssFeedPath("/reading/feed"); // true
15
+ * isRssFeedPath("/api/posts"); // false
16
+ * ```
17
+ */
18
+ export function isRssFeedPath(path: string): boolean {
19
+ if (path === "/feed" || path.startsWith("/feed/")) return true;
20
+ if (NON_FEED_PATH_PREFIXES.some((prefix) => hasPathPrefix(path, prefix))) {
21
+ return false;
22
+ }
23
+
24
+ return path.endsWith("/feed") || path.endsWith("/feed/atom.xml");
25
+ }
@@ -0,0 +1,64 @@
1
+ import type { Post } from "../types.js";
2
+ import { now, toISOString } from "./time.js";
3
+
4
+ /** Cache policy for dynamic Atom feed responses. */
5
+ export const RSS_FEED_CACHE_CONTROL = "public, max-age=60";
6
+
7
+ /**
8
+ * Convert an RSS publication delay into the exclusive upper bound expected by
9
+ * Post service queries.
10
+ *
11
+ * Posts published exactly `delaySeconds` ago are eligible. Because publication
12
+ * timestamps use whole seconds while `publishedBefore` is exclusive, the bound
13
+ * is one second after the latest eligible timestamp.
14
+ *
15
+ * @param delaySeconds - Non-negative publication delay in seconds
16
+ * @param currentTime - Current Unix timestamp, injectable for deterministic use
17
+ * @returns Exclusive `publishedBefore` timestamp for RSS queries
18
+ * @example
19
+ * ```ts
20
+ * getRssPublishedBefore(300, 1_000); // 701, so publishedAt <= 700 is eligible
21
+ * ```
22
+ */
23
+ export function getRssPublishedBefore(
24
+ delaySeconds: number,
25
+ currentTime = now(),
26
+ ): number {
27
+ return currentTime - delaySeconds + 1;
28
+ }
29
+
30
+ /**
31
+ * Resolve the Atom `updated` timestamp for a Thread entry from content that is
32
+ * actually present in the feed.
33
+ *
34
+ * @param root - Thread root used as a fallback when no Thread rows are loaded
35
+ * @param thread - Eligible Thread Posts included in the Atom entry
36
+ * @param additionalTimestamps - Other entry updates, such as Collection membership
37
+ * @returns Latest update timestamp as an ISO 8601 string
38
+ * @example
39
+ * ```ts
40
+ * getFeedEntryUpdatedAt(root, [root, reply], [collectedAt]);
41
+ * ```
42
+ */
43
+ export function getFeedEntryUpdatedAt(
44
+ root: Pick<Post, "publishedAt" | "updatedAt">,
45
+ thread: readonly Pick<Post, "publishedAt" | "updatedAt">[] | undefined,
46
+ additionalTimestamps: readonly (number | null | undefined)[] = [],
47
+ ): string {
48
+ let updatedAt = Math.max(root.updatedAt, root.publishedAt ?? root.updatedAt);
49
+
50
+ for (const post of thread ?? []) {
51
+ updatedAt = Math.max(
52
+ updatedAt,
53
+ post.updatedAt,
54
+ post.publishedAt ?? post.updatedAt,
55
+ );
56
+ }
57
+ for (const timestamp of additionalTimestamps) {
58
+ if (timestamp !== null && timestamp !== undefined) {
59
+ updatedAt = Math.max(updatedAt, timestamp);
60
+ }
61
+ }
62
+
63
+ return toISOString(updatedAt);
64
+ }
@@ -18,6 +18,16 @@ export interface FootnoteDefinitionTokenData {
18
18
  contentMarkdown: string;
19
19
  }
20
20
 
21
+ export interface LegacyFootnoteUpgradeResult {
22
+ doc: JSONContent;
23
+ upgraded: boolean;
24
+ }
25
+
26
+ interface LegacyFootnoteReference {
27
+ label: string;
28
+ node: JSONContent;
29
+ }
30
+
21
31
  export function normalizeFootnoteLabel(label: unknown): string {
22
32
  if (typeof label !== "string") return "";
23
33
  return label.trim().replace(/\s+/g, " ");
@@ -82,6 +92,260 @@ export function normalizeFootnoteArtifacts(doc: JSONContent): JSONContent {
82
92
  return content.length === doc.content.length ? doc : { ...doc, content };
83
93
  }
84
94
 
95
+ function getLinkHref(node: JSONContent): string {
96
+ const linkMarks = (node.marks ?? []).filter((mark) => mark.type === "link");
97
+ if (linkMarks.length !== 1) return "";
98
+
99
+ const href = linkMarks[0]?.attrs?.href;
100
+ return typeof href === "string" ? href.trim() : "";
101
+ }
102
+
103
+ function decodeHashTarget(href: string): string {
104
+ if (!href.startsWith("#")) return "";
105
+
106
+ try {
107
+ return decodeURIComponent(href.slice(1));
108
+ } catch {
109
+ return href.slice(1);
110
+ }
111
+ }
112
+
113
+ function normalizeLegacyTargetId(value: string): string {
114
+ return value.replace(/^user-content-/i, "").toLowerCase();
115
+ }
116
+
117
+ function getLegacyReferenceLabel(node: JSONContent): string {
118
+ if (node.type !== "text" || typeof node.text !== "string") return "";
119
+
120
+ const text = node.text.trim();
121
+ const displayMatch = text.match(/^\[\^?([^\]\n]+)\]$/);
122
+ const label = normalizeFootnoteLabel(displayMatch?.[1] ?? text);
123
+ if (!label) return "";
124
+
125
+ const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
126
+ const expectedTargetId = `fn-${getFootnoteDomId(label)}`.toLowerCase();
127
+ return targetId === expectedTargetId ? label : "";
128
+ }
129
+
130
+ function collectLegacyReferences(
131
+ node: JSONContent,
132
+ references: LegacyFootnoteReference[],
133
+ ): void {
134
+ const label = getLegacyReferenceLabel(node);
135
+ if (label) {
136
+ references.push({ label, node });
137
+ return;
138
+ }
139
+
140
+ for (const child of node.content ?? []) {
141
+ collectLegacyReferences(child, references);
142
+ }
143
+ }
144
+
145
+ function isBacklinkText(value: string | undefined): boolean {
146
+ return /^(?:↩(?:︎)?|↵)(?:\s*\d+)?$/.test(value?.trim() ?? "");
147
+ }
148
+
149
+ function getLegacyBacklinkLabel(
150
+ node: JSONContent,
151
+ labels: readonly string[],
152
+ ): string {
153
+ if (node.type !== "text" || !isBacklinkText(node.text)) return "";
154
+
155
+ const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
156
+ if (!targetId.startsWith("fnref-")) return "";
157
+
158
+ for (const label of labels) {
159
+ const base = `fnref-${getFootnoteDomId(label)}`.toLowerCase();
160
+ const occurrence = targetId.startsWith(`${base}-`)
161
+ ? targetId.slice(base.length + 1)
162
+ : "";
163
+ if (targetId === base || /^\d+$/.test(occurrence)) {
164
+ return label;
165
+ }
166
+ }
167
+
168
+ return "";
169
+ }
170
+
171
+ function collectLegacyBacklinkLabels(
172
+ node: JSONContent,
173
+ labels: readonly string[],
174
+ found: Set<string>,
175
+ ): void {
176
+ const label = getLegacyBacklinkLabel(node, labels);
177
+ if (label) {
178
+ found.add(getFootnoteLabelKey(label));
179
+ return;
180
+ }
181
+
182
+ for (const child of node.content ?? []) {
183
+ collectLegacyBacklinkLabels(child, labels, found);
184
+ }
185
+ }
186
+
187
+ function removeLegacyBacklinks(
188
+ node: JSONContent,
189
+ label: string,
190
+ ): JSONContent | null {
191
+ if (
192
+ getFootnoteLabelKey(getLegacyBacklinkLabel(node, [label])) ===
193
+ getFootnoteLabelKey(label)
194
+ ) {
195
+ return null;
196
+ }
197
+
198
+ if (!node.content) return node;
199
+
200
+ const content = node.content.flatMap((child) => {
201
+ const normalized = removeLegacyBacklinks(child, label);
202
+ return normalized ? [normalized] : [];
203
+ });
204
+
205
+ if (content.length === node.content.length) {
206
+ const unchanged = content.every(
207
+ (child, index) => child === node.content?.[index],
208
+ );
209
+ if (unchanged) return node;
210
+ }
211
+
212
+ return content.length > 0 ? { ...node, content } : { ...node, content: [] };
213
+ }
214
+
215
+ function replaceLegacyReferences(
216
+ node: JSONContent,
217
+ labelsByNode: Map<JSONContent, string>,
218
+ ): JSONContent {
219
+ const label = labelsByNode.get(node);
220
+ if (label) {
221
+ return { type: "footnoteReference", attrs: { label } };
222
+ }
223
+
224
+ if (!node.content) return node;
225
+
226
+ const content = node.content.map((child) =>
227
+ replaceLegacyReferences(child, labelsByNode),
228
+ );
229
+ const unchanged = content.every(
230
+ (child, index) => child === node.content?.[index],
231
+ );
232
+ return unchanged ? node : { ...node, content };
233
+ }
234
+
235
+ function hasCanonicalFootnoteNode(node: JSONContent): boolean {
236
+ if (node.type === "footnoteReference" || node.type === "footnoteDefinition") {
237
+ return true;
238
+ }
239
+
240
+ return (node.content ?? []).some(hasCanonicalFootnoteNode);
241
+ }
242
+
243
+ /**
244
+ * Upgrades the historical footnote shape that old rich-text pastes stored as
245
+ * ordinary fragment links plus a trailing ordered list.
246
+ *
247
+ * Conversion is deliberately all-or-nothing. The document must end in an
248
+ * `hr` and ordered list, every definition item must contain a matching
249
+ * `#fnref-*` backlink, and every `#fn-*` reference must have exactly one
250
+ * definition label. This keeps normal fragment links and numbered lists
251
+ * untouched.
252
+ *
253
+ * @param doc - Parsed TipTap document
254
+ * @returns The canonical document and whether a legacy footnote set changed
255
+ * @example
256
+ * ```ts
257
+ * const result = upgradeLegacyFootnotes({
258
+ * type: "doc",
259
+ * content: [],
260
+ * });
261
+ * // { doc: { type: "doc", content: [] }, upgraded: false }
262
+ * ```
263
+ */
264
+ export function upgradeLegacyFootnotes(
265
+ doc: JSONContent,
266
+ ): LegacyFootnoteUpgradeResult {
267
+ if (
268
+ doc.type !== "doc" ||
269
+ !Array.isArray(doc.content) ||
270
+ doc.content.length < 3 ||
271
+ hasCanonicalFootnoteNode(doc)
272
+ ) {
273
+ return { doc, upgraded: false };
274
+ }
275
+
276
+ const definitionList = doc.content.at(-1);
277
+ const separator = doc.content.at(-2);
278
+ if (
279
+ definitionList?.type !== "orderedList" ||
280
+ separator?.type !== "horizontalRule" ||
281
+ !Array.isArray(definitionList.content) ||
282
+ definitionList.content.length === 0 ||
283
+ definitionList.content.some((item) => item.type !== "listItem")
284
+ ) {
285
+ return { doc, upgraded: false };
286
+ }
287
+
288
+ const bodyNodes = doc.content.slice(0, -2);
289
+ const references: LegacyFootnoteReference[] = [];
290
+ for (const node of bodyNodes) collectLegacyReferences(node, references);
291
+ if (references.length === 0) return { doc, upgraded: false };
292
+
293
+ const labelsByKey = new Map<string, string>();
294
+ for (const reference of references) {
295
+ const key = getFootnoteLabelKey(reference.label);
296
+ if (!labelsByKey.has(key)) labelsByKey.set(key, reference.label);
297
+ }
298
+ const labels = [...labelsByKey.values()].sort(
299
+ (left, right) =>
300
+ getFootnoteDomId(right).length - getFootnoteDomId(left).length,
301
+ );
302
+ if (labels.length !== definitionList.content.length) {
303
+ return { doc, upgraded: false };
304
+ }
305
+
306
+ const definitions: JSONContent[] = [];
307
+ const definedLabels = new Set<string>();
308
+ for (const item of definitionList.content) {
309
+ const backlinkLabels = new Set<string>();
310
+ collectLegacyBacklinkLabels(item, labels, backlinkLabels);
311
+ if (backlinkLabels.size !== 1) return { doc, upgraded: false };
312
+
313
+ const key = [...backlinkLabels][0];
314
+ if (!key) return { doc, upgraded: false };
315
+ const label = labelsByKey.get(key);
316
+ if (!label || definedLabels.has(key)) return { doc, upgraded: false };
317
+
318
+ definedLabels.add(key);
319
+ const content = (item.content ?? []).flatMap((child) => {
320
+ const normalized = removeLegacyBacklinks(child, label);
321
+ return normalized ? [normalized] : [];
322
+ });
323
+ definitions.push({
324
+ type: "footnoteDefinition",
325
+ attrs: { label },
326
+ content: content.length > 0 ? content : [{ type: "paragraph" }],
327
+ });
328
+ }
329
+
330
+ if (definedLabels.size !== labelsByKey.size) {
331
+ return { doc, upgraded: false };
332
+ }
333
+
334
+ const labelsByNode = new Map(
335
+ references.map((reference) => [reference.node, reference.label] as const),
336
+ );
337
+ return {
338
+ doc: {
339
+ ...doc,
340
+ content: [
341
+ ...bodyNodes.map((node) => replaceLegacyReferences(node, labelsByNode)),
342
+ ...definitions,
343
+ ],
344
+ },
345
+ upgraded: true,
346
+ };
347
+ }
348
+
85
349
  /**
86
350
  * Parses one Markdown footnote definition from the start of `src`.
87
351
  *