jekyll-theme-modootoday 1.0.0

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 (352) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +182 -0
  3. data/LICENSE +21 -0
  4. data/README.ko.md +115 -0
  5. data/README.md +112 -0
  6. data/RULES.md +120 -0
  7. data/_includes/footer-custom.html +5 -0
  8. data/_includes/head-custom.html +1 -0
  9. data/_includes/modootoday/actions.html +17 -0
  10. data/_includes/modootoday/archive-years.html +30 -0
  11. data/_includes/modootoday/archive.html +44 -0
  12. data/_includes/modootoday/blog/card.html +23 -0
  13. data/_includes/modootoday/blog/config.html +17 -0
  14. data/_includes/modootoday/blog/list.html +13 -0
  15. data/_includes/modootoday/blog/pager.html +15 -0
  16. data/_includes/modootoday/blog/reading-time.html +10 -0
  17. data/_includes/modootoday/blog/select.html +18 -0
  18. data/_includes/modootoday/brand/icon.html +39 -0
  19. data/_includes/modootoday/brand/logo.html +7 -0
  20. data/_includes/modootoday/breadcrumb.html +22 -0
  21. data/_includes/modootoday/config.html +41 -0
  22. data/_includes/modootoday/detail/cover.html +5 -0
  23. data/_includes/modootoday/detail/cta.html +13 -0
  24. data/_includes/modootoday/detail/facts.html +31 -0
  25. data/_includes/modootoday/detail/jsonld.html +34 -0
  26. data/_includes/modootoday/directory/date-tile.html +2 -0
  27. data/_includes/modootoday/directory/filter.html +7 -0
  28. data/_includes/modootoday/directory/ics.html +17 -0
  29. data/_includes/modootoday/directory/item.html +18 -0
  30. data/_includes/modootoday/directory/items.html +4 -0
  31. data/_includes/modootoday/directory/jsonld.html +5 -0
  32. data/_includes/modootoday/directory/list.html +16 -0
  33. data/_includes/modootoday/directory/values.html +6 -0
  34. data/_includes/modootoday/doc/page-actions.html +15 -0
  35. data/_includes/modootoday/doc/pagination.html +24 -0
  36. data/_includes/modootoday/doc/sidebar-item.html +11 -0
  37. data/_includes/modootoday/doc/sidebar.html +28 -0
  38. data/_includes/modootoday/doc/twin.html +8 -0
  39. data/_includes/modootoday/docs-extra/head.html +7 -0
  40. data/_includes/modootoday/docs-extra/jsonld.html +19 -0
  41. data/_includes/modootoday/head-meta.html +90 -0
  42. data/_includes/modootoday/home-url.html +4 -0
  43. data/_includes/modootoday/i18n/ar.html +2 -0
  44. data/_includes/modootoday/i18n/de.html +2 -0
  45. data/_includes/modootoday/i18n/el.html +2 -0
  46. data/_includes/modootoday/i18n/es.html +2 -0
  47. data/_includes/modootoday/i18n/fa.html +2 -0
  48. data/_includes/modootoday/i18n/fr.html +2 -0
  49. data/_includes/modootoday/i18n/id.html +2 -0
  50. data/_includes/modootoday/i18n/it.html +2 -0
  51. data/_includes/modootoday/i18n/ja.html +2 -0
  52. data/_includes/modootoday/i18n/ko.html +2 -0
  53. data/_includes/modootoday/i18n/ml.html +2 -0
  54. data/_includes/modootoday/i18n/name.html +2 -0
  55. data/_includes/modootoday/i18n/pl.html +2 -0
  56. data/_includes/modootoday/i18n/pt-BR.html +2 -0
  57. data/_includes/modootoday/i18n/ro.html +2 -0
  58. data/_includes/modootoday/i18n/ru.html +2 -0
  59. data/_includes/modootoday/i18n/th.html +2 -0
  60. data/_includes/modootoday/i18n/tr.html +2 -0
  61. data/_includes/modootoday/i18n/uk.html +2 -0
  62. data/_includes/modootoday/i18n/ur-Latn.html +2 -0
  63. data/_includes/modootoday/i18n/vi.html +2 -0
  64. data/_includes/modootoday/i18n/zh-CN.html +2 -0
  65. data/_includes/modootoday/i18n/zh-TW.html +2 -0
  66. data/_includes/modootoday/i18n.html +163 -0
  67. data/_includes/modootoday/jsonld/article.html +15 -0
  68. data/_includes/modootoday/jsonld/author.html +8 -0
  69. data/_includes/modootoday/jsonld/breadcrumb.html +17 -0
  70. data/_includes/modootoday/jsonld/entity.html +16 -0
  71. data/_includes/modootoday/jsonld/graph.html +24 -0
  72. data/_includes/modootoday/jsonld/profile.html +20 -0
  73. data/_includes/modootoday/jsonld/props.html +13 -0
  74. data/_includes/modootoday/jsonld/publisher.html +11 -0
  75. data/_includes/modootoday/jsonld/type.html +29 -0
  76. data/_includes/modootoday/jsonld/webpage.html +17 -0
  77. data/_includes/modootoday/jsonld/website.html +9 -0
  78. data/_includes/modootoday/lang-switch.html +31 -0
  79. data/_includes/modootoday/link.html +3 -0
  80. data/_includes/modootoday/links/attrs.html +9 -0
  81. data/_includes/modootoday/links/content.html +16 -0
  82. data/_includes/modootoday/markdown/c/details.html +6 -0
  83. data/_includes/modootoday/markdown/c/faq.html +2 -0
  84. data/_includes/modootoday/markdown/c/filetree.html +3 -0
  85. data/_includes/modootoday/markdown/c/gallery.html +5 -0
  86. data/_includes/modootoday/markdown/c/linkcards.html +2 -0
  87. data/_includes/modootoday/markdown/c/params.html +3 -0
  88. data/_includes/modootoday/markdown/c/pricing.html +10 -0
  89. data/_includes/modootoday/markdown/c/quote.html +6 -0
  90. data/_includes/modootoday/markdown/c/subscribe.html +6 -0
  91. data/_includes/modootoday/markdown/components.html +114 -0
  92. data/_includes/modootoday/markdown/inline.html +46 -0
  93. data/_includes/modootoday/markdown/llms-pages.html +14 -0
  94. data/_includes/modootoday/markdown/raw-body.html +6 -0
  95. data/_includes/modootoday/markdown/sections.html +43 -0
  96. data/_includes/modootoday/marketing/head.html +6 -0
  97. data/_includes/modootoday/meta/byline.html +24 -0
  98. data/_includes/modootoday/meta/post-nav.html +16 -0
  99. data/_includes/modootoday/meta/related.html +20 -0
  100. data/_includes/modootoday/meta/series.html +11 -0
  101. data/_includes/modootoday/meta/share.html +4 -0
  102. data/_includes/modootoday/nav.html +19 -0
  103. data/_includes/modootoday/news/badge.html +4 -0
  104. data/_includes/modootoday/news/front.html +30 -0
  105. data/_includes/modootoday/news/notes.html +10 -0
  106. data/_includes/modootoday/page-meta.html +16 -0
  107. data/_includes/modootoday/pages/error.html +13 -0
  108. data/_includes/modootoday/pages/hero/scene.html +27 -0
  109. data/_includes/modootoday/pages/hero/sparks.html +6 -0
  110. data/_includes/modootoday/pages/hero.html +11 -0
  111. data/_includes/modootoday/pages/search-form.html +14 -0
  112. data/_includes/modootoday/search.json +11 -0
  113. data/_includes/modootoday/stylesheets.html +36 -0
  114. data/_includes/modootoday/t.html +7 -0
  115. data/_includes/modootoday/theme-color.html +19 -0
  116. data/_includes/modootoday/toc.html +17 -0
  117. data/_includes/modootoday/tokens.html +11 -0
  118. data/_includes/sections/showcase.html +18 -0
  119. data/_layouts/archive.html +6 -0
  120. data/_layouts/author.html +19 -0
  121. data/_layouts/blog.html +28 -0
  122. data/_layouts/book.html +21 -0
  123. data/_layouts/brand.html +21 -0
  124. data/_layouts/case-study.html +5 -0
  125. data/_layouts/changelog.html +24 -0
  126. data/_layouts/contact.html +27 -0
  127. data/_layouts/course.html +21 -0
  128. data/_layouts/default.html +87 -0
  129. data/_layouts/detail.html +31 -0
  130. data/_layouts/directory.html +12 -0
  131. data/_layouts/doc.html +27 -0
  132. data/_layouts/download.html +21 -0
  133. data/_layouts/episode.html +5 -0
  134. data/_layouts/error.html +7 -0
  135. data/_layouts/event.html +5 -0
  136. data/_layouts/events.html +6 -0
  137. data/_layouts/feature.html +18 -0
  138. data/_layouts/feed.html +35 -0
  139. data/_layouts/glossary.html +24 -0
  140. data/_layouts/home.html +7 -0
  141. data/_layouts/hub.html +16 -0
  142. data/_layouts/integrations.html +7 -0
  143. data/_layouts/job.html +5 -0
  144. data/_layouts/jobs.html +7 -0
  145. data/_layouts/legal.html +21 -0
  146. data/_layouts/links.html +18 -0
  147. data/_layouts/llms-full.html +8 -0
  148. data/_layouts/llms.html +32 -0
  149. data/_layouts/minimal.html +8 -0
  150. data/_layouts/news-article.html +6 -0
  151. data/_layouts/news.html +22 -0
  152. data/_layouts/portfolio.html +7 -0
  153. data/_layouts/post.html +54 -0
  154. data/_layouts/pricing.html +32 -0
  155. data/_layouts/project.html +5 -0
  156. data/_layouts/redirect.html +21 -0
  157. data/_layouts/reference.html +24 -0
  158. data/_layouts/resume.html +50 -0
  159. data/_layouts/roadmap.html +22 -0
  160. data/_layouts/search.html +8 -0
  161. data/_layouts/sponsors.html +7 -0
  162. data/_layouts/team.html +7 -0
  163. data/_layouts/twin.html +2 -0
  164. data/_layouts/wide.html +7 -0
  165. data/_sass/modootoday/_access.scss +12 -0
  166. data/_sass/modootoday/_core.scss +18 -0
  167. data/_sass/modootoday/_settings.scss +5 -0
  168. data/_sass/modootoday/_sheet.scss +6 -0
  169. data/_sass/modootoday/base/_bidi.scss +8 -0
  170. data/_sass/modootoday/base/_code.scss +18 -0
  171. data/_sass/modootoday/base/_interaction.scss +6 -0
  172. data/_sass/modootoday/base/_prose.scss +32 -0
  173. data/_sass/modootoday/base/_reset.scss +14 -0
  174. data/_sass/modootoday/base/_table.scss +8 -0
  175. data/_sass/modootoday/blog/_article.scss +27 -0
  176. data/_sass/modootoday/blog/_cards.scss +36 -0
  177. data/_sass/modootoday/blog/_index.scss +10 -0
  178. data/_sass/modootoday/blog/_news.scss +26 -0
  179. data/_sass/modootoday/blog/_post.scss +9 -0
  180. data/_sass/modootoday/components/core/chrome/_banner.scss +7 -0
  181. data/_sass/modootoday/components/core/chrome/_breadcrumb.scss +10 -0
  182. data/_sass/modootoday/components/core/chrome/_nav.scss +39 -0
  183. data/_sass/modootoday/components/core/chrome/_pagination.scss +10 -0
  184. data/_sass/modootoday/components/core/chrome/_theme-toggle.scss +16 -0
  185. data/_sass/modootoday/components/core/inline/_badge.scss +15 -0
  186. data/_sass/modootoday/components/core/inline/_callout.scss +14 -0
  187. data/_sass/modootoday/components/core/inline/_code-copy.scss +11 -0
  188. data/_sass/modootoday/components/core/inline/_code.scss +11 -0
  189. data/_sass/modootoday/components/core/inline/_cta.scss +11 -0
  190. data/_sass/modootoday/components/core/inline/_embed.scss +6 -0
  191. data/_sass/modootoday/components/core/inline/_figure.scss +9 -0
  192. data/_sass/modootoday/components/core/inline/_footnotes.scss +6 -0
  193. data/_sass/modootoday/components/core/inline/_hero.scss +8 -0
  194. data/_sass/modootoday/components/core/inline/_kbd.scss +6 -0
  195. data/_sass/modootoday/components/core/inline/_progress.scss +8 -0
  196. data/_sass/modootoday/components/doc/_page-actions.scss +10 -0
  197. data/_sass/modootoday/components/doc/_sidebar.scss +31 -0
  198. data/_sass/modootoday/components/doc/_toc.scss +19 -0
  199. data/_sass/modootoday/components/loading/_loading.scss +33 -0
  200. data/_sass/modootoday/components/search/_search.scss +36 -0
  201. data/_sass/modootoday/components/section/showcase/_card.scss +15 -0
  202. data/_sass/modootoday/components/section/showcase/_cta.scss +7 -0
  203. data/_sass/modootoday/components/section/showcase/_gallery.scss +15 -0
  204. data/_sass/modootoday/components/section/showcase/_linkcards.scss +12 -0
  205. data/_sass/modootoday/components/section/showcase/_pricing.scss +12 -0
  206. data/_sass/modootoday/components/section/showcase/_quote.scss +7 -0
  207. data/_sass/modootoday/components/section/showcase/_subscribe.scss +9 -0
  208. data/_sass/modootoday/components/section/structure/_accordion.scss +15 -0
  209. data/_sass/modootoday/components/section/structure/_details.scss +15 -0
  210. data/_sass/modootoday/components/section/structure/_faq.scss +5 -0
  211. data/_sass/modootoday/components/section/structure/_filetree.scss +13 -0
  212. data/_sass/modootoday/components/section/structure/_params.scss +8 -0
  213. data/_sass/modootoday/components/section/structure/_steps.scss +13 -0
  214. data/_sass/modootoday/components/section/structure/_tabs.scss +21 -0
  215. data/_sass/modootoday/components/section/structure/_timeline.scss +10 -0
  216. data/_sass/modootoday/detail/_index.scss +8 -0
  217. data/_sass/modootoday/detail/_page.scss +25 -0
  218. data/_sass/modootoday/directory/_filter.scss +12 -0
  219. data/_sass/modootoday/directory/_grid.scss +18 -0
  220. data/_sass/modootoday/directory/_index.scss +11 -0
  221. data/_sass/modootoday/directory/_presets.scss +16 -0
  222. data/_sass/modootoday/docs-extra/_index.scss +7 -0
  223. data/_sass/modootoday/docs-extra/_page.scss +37 -0
  224. data/_sass/modootoday/hero/_index.scss +6 -0
  225. data/_sass/modootoday/hero/_layout.scss +28 -0
  226. data/_sass/modootoday/hero/_scene.scss +20 -0
  227. data/_sass/modootoday/kit/_button.scss +8 -0
  228. data/_sass/modootoday/layout/_page-meta.scss +9 -0
  229. data/_sass/modootoday/layout/_safe-area.scss +9 -0
  230. data/_sass/modootoday/layout/_shell.scss +56 -0
  231. data/_sass/modootoday/marketing/_forms.scss +11 -0
  232. data/_sass/modootoday/marketing/_index.scss +10 -0
  233. data/_sass/modootoday/marketing/_page.scss +31 -0
  234. data/_sass/modootoday/marketing/_pricing.scss +15 -0
  235. data/_sass/modootoday/pages/_actions.scss +11 -0
  236. data/_sass/modootoday/pages/_error.scss +7 -0
  237. data/_sass/modootoday/pages/_index.scss +11 -0
  238. data/_sass/modootoday/pages/_legal.scss +17 -0
  239. data/_sass/modootoday/pages/_search.scss +9 -0
  240. data/_sass/modootoday/pages/_shells.scss +11 -0
  241. data/_sass/modootoday/resume/_index.scss +5 -0
  242. data/_sass/modootoday/resume/_page.scss +27 -0
  243. data/_sass/modootoday/tokens/_emit.scss +15 -0
  244. data/_sass/modootoday/tokens/_primitive.scss +18 -0
  245. data/_sass/modootoday/tokens/_semantic.scss +37 -0
  246. data/_sass/modootoday/tools/_functions.scss +2 -0
  247. data/_sass/modootoday/tools/_img-dark.scss +12 -0
  248. data/_sass/modootoday/tools/_mixins.scss +8 -0
  249. data/_sass/modootoday.scss +40 -0
  250. data/assets/css/modootoday/doc.scss +10 -0
  251. data/assets/css/modootoday/loading.scss +8 -0
  252. data/assets/css/modootoday/search.scss +8 -0
  253. data/assets/css/modootoday/showcase/card.scss +7 -0
  254. data/assets/css/modootoday/showcase/cta.scss +7 -0
  255. data/assets/css/modootoday/showcase/gallery.scss +7 -0
  256. data/assets/css/modootoday/showcase/linkcards.scss +7 -0
  257. data/assets/css/modootoday/showcase/pricing.scss +7 -0
  258. data/assets/css/modootoday/showcase/quote.scss +7 -0
  259. data/assets/css/modootoday/showcase/subscribe.scss +7 -0
  260. data/assets/css/modootoday/structure/accordion.scss +7 -0
  261. data/assets/css/modootoday/structure/details.scss +7 -0
  262. data/assets/css/modootoday/structure/faq.scss +7 -0
  263. data/assets/css/modootoday/structure/filetree.scss +7 -0
  264. data/assets/css/modootoday/structure/params.scss +7 -0
  265. data/assets/css/modootoday/structure/steps.scss +7 -0
  266. data/assets/css/modootoday/structure/tabs.scss +7 -0
  267. data/assets/css/modootoday/structure/timeline.scss +7 -0
  268. data/assets/css/modootoday-blog.scss +7 -0
  269. data/assets/css/modootoday-detail.scss +7 -0
  270. data/assets/css/modootoday-directory.scss +7 -0
  271. data/assets/css/modootoday-docs-extra.scss +7 -0
  272. data/assets/css/modootoday-hero.scss +7 -0
  273. data/assets/css/modootoday-marketing.scss +7 -0
  274. data/assets/css/modootoday-pages.scss +7 -0
  275. data/assets/css/modootoday-resume.scss +7 -0
  276. data/assets/css/modootoday.scss +17 -0
  277. data/assets/js/modootoday/accordion.js +13 -0
  278. data/assets/js/modootoday/code-copy.js +29 -0
  279. data/assets/js/modootoday/menu.js +19 -0
  280. data/assets/js/modootoday/scroll-region.js +14 -0
  281. data/assets/js/modootoday/search-keys.js +18 -0
  282. data/assets/js/modootoday/search.js +31 -0
  283. data/assets/js/modootoday/share.js +16 -0
  284. data/assets/js/modootoday/tabs.js +26 -0
  285. data/assets/js/modootoday/theme-init.js +2 -0
  286. data/assets/js/modootoday/theme-toggle.js +17 -0
  287. data/assets/modootoday/apple-touch-icon.png +0 -0
  288. data/assets/modootoday/favicon-32.png +0 -0
  289. data/assets/modootoday/favicon.svg +1 -0
  290. data/assets/modootoday/hero/dawn-1280.webp +0 -0
  291. data/assets/modootoday/hero/dawn-640.webp +0 -0
  292. data/assets/modootoday/hero/dawn-960.webp +0 -0
  293. data/exe/modootoday-check +7 -0
  294. data/i18n/ar.yml +130 -0
  295. data/i18n/de.yml +130 -0
  296. data/i18n/el.yml +130 -0
  297. data/i18n/en.yml +131 -0
  298. data/i18n/es.yml +130 -0
  299. data/i18n/fa.yml +130 -0
  300. data/i18n/fr.yml +130 -0
  301. data/i18n/glossary.yml +61 -0
  302. data/i18n/id.yml +130 -0
  303. data/i18n/it.yml +130 -0
  304. data/i18n/ja.yml +130 -0
  305. data/i18n/ko.yml +130 -0
  306. data/i18n/languages.yml +25 -0
  307. data/i18n/ml.yml +130 -0
  308. data/i18n/pl.yml +130 -0
  309. data/i18n/pt-BR.yml +130 -0
  310. data/i18n/ro.yml +130 -0
  311. data/i18n/ru.yml +130 -0
  312. data/i18n/th.yml +130 -0
  313. data/i18n/tr.yml +130 -0
  314. data/i18n/uk.yml +130 -0
  315. data/i18n/ur-Latn.yml +130 -0
  316. data/i18n/vi.yml +130 -0
  317. data/i18n/zh-CN.yml +130 -0
  318. data/i18n/zh-TW.yml +130 -0
  319. data/lib/jekyll-theme-modootoday.rb +59 -0
  320. data/lib/modootoday/check/a11y/content.rb +57 -0
  321. data/lib/modootoday/check/a11y/new_window.rb +64 -0
  322. data/lib/modootoday/check/blog.rb +72 -0
  323. data/lib/modootoday/check/build_output.rb +39 -0
  324. data/lib/modootoday/check/built_parts.rb +40 -0
  325. data/lib/modootoday/check/cli.rb +61 -0
  326. data/lib/modootoday/check/component_rules.rb +54 -0
  327. data/lib/modootoday/check/context.rb +41 -0
  328. data/lib/modootoday/check/gh_render.rb +21 -0
  329. data/lib/modootoday/check/github_pages.rb +23 -0
  330. data/lib/modootoday/check/i18n.rb +92 -0
  331. data/lib/modootoday/check/layouts/detail.rb +52 -0
  332. data/lib/modootoday/check/layouts/directory.rb +36 -0
  333. data/lib/modootoday/check/layouts/docs_extra.rb +84 -0
  334. data/lib/modootoday/check/layouts/marketing.rb +64 -0
  335. data/lib/modootoday/check/layouts/shape.rb +38 -0
  336. data/lib/modootoday/check/layouts/shells.rb +47 -0
  337. data/lib/modootoday/check/layouts.rb +37 -0
  338. data/lib/modootoday/check/markdown.rb +39 -0
  339. data/lib/modootoday/check/page_sections.rb +72 -0
  340. data/lib/modootoday/check/page_syntax.rb +104 -0
  341. data/lib/modootoday/check/page_values.rb +30 -0
  342. data/lib/modootoday/check/pages.rb +31 -0
  343. data/lib/modootoday/check/registry.rb +69 -0
  344. data/lib/modootoday/check/site_config.rb +74 -0
  345. data/lib/modootoday/check/slots.rb +50 -0
  346. data/lib/modootoday/check/structured_data.rb +97 -0
  347. data/lib/modootoday/check/stylesheets.rb +28 -0
  348. data/lib/modootoday/check/twins.rb +69 -0
  349. data/lib/modootoday/check.rb +22 -0
  350. data/lib/modootoday/schema/rules.rb +49 -0
  351. data/lib/modootoday/schema/vocab.txt +2478 -0
  352. metadata +420 -0
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # Rules 0-1: front matter present, fence info strings, alerts, forbidden syntax, section headings.
6
+ module PageSyntax
7
+ KNOWN = %w[NOTE TIP IMPORTANT WARNING CAUTION].freeze
8
+ NESTED_ALERT = "alert nested in list/table/quote (GitHub renders only top-level alerts; this shows as a plain quote); start '> [!TYPE]' at column 0"
9
+
10
+ module_function
11
+
12
+ # Returns the number of top-level '## ' headings (used by the sections: rule).
13
+ def call(ctx, f, text, b, code)
14
+ front_matter(ctx, f, text)
15
+ fences(ctx, f, b)
16
+ alerts(ctx, f, code.lines)
17
+ html_free = Markdown.strip_comments(code)
18
+ forbidden(ctx, f, code, html_free)
19
+ headings(ctx, f, html_free)
20
+ end
21
+
22
+ # 0. every content page needs front matter (may be empty "---\n---"); without it,
23
+ # GitHub Pages (jekyll-optional-front-matter) renders the page AND copies the raw .md to _site.
24
+ def front_matter(ctx, f, text)
25
+ ctx.errs << "#{ctx.rel(f)}: missing front matter (add an empty '---' / '---' block at the top)" unless (text.start_with?("---\n") && text =~ Markdown::FM) || ctx.source_mode?
26
+ end
27
+
28
+ # 1a. code fence info string: one language word only (kramdown 2.4 turns "sh {x}" into inline code)
29
+ def fences(ctx, f, b)
30
+ Markdown.strip_fences(b) do |n, info|
31
+ next if info.empty? || info.match?(/\A[\w#+.-]+\z/)
32
+ ctx.errs << "#{ctx.rel(f)}:#{n}: code fence info string must be a single language word (got #{info.inspect}); attributes like {title=…} break kramdown"
33
+ end
34
+ end
35
+
36
+ # 1b. alerts (GitHub extension): case-insensitive markers, registered types only
37
+ def alerts(ctx, f, lines)
38
+ errs, r = ctx.errs, ctx.rel(f)
39
+ lines.each_with_index do |l, i|
40
+ n = i + 1
41
+ errs << "#{r}:#{n}: tab after '>' (kramdown reads it as an indented code block); use a space" if l =~ /^ {0,3}>\t/
42
+ errs << "#{r}:#{n}: formatted alert marker (e.g. **[!NOTE]**) is not an alert anywhere; write > [!NOTE]" if l =~ /^ {0,3}> ?\*{1,2}\[!\w+\]/
43
+ ctx.warns << "#{r}:#{n}: '[! TYPE]' with a space is not an alert (renders as a plain quote)" if l =~ /^ {0,3}> ?\[! +\w+\]/
44
+ next unless (m = l.match(/^ {0,3}> ?\[!(\w+)\](.*)$/))
45
+ t, after = m[1], m[2]
46
+ next errs << "#{r}:#{n}: unregistered alert type [!#{t}] (allowed: #{KNOWN.join('/')}; any case)" unless KNOWN.include?(t.upcase)
47
+ errs << "#{r}:#{n}: text after [!#{t}] (custom titles are not supported; GitHub shows a plain quote)" unless after.strip.empty?
48
+ rest = lines[(i + 1)..].to_a.take_while { |x| x.strip != "" }
49
+ errs << "#{r}:#{n}: empty alert [!#{t}] (GitHub shows a plain quote); add body text" if rest.all? { |x| x.sub(/^ {0,3}>/, "").strip.empty? }
50
+ end
51
+ end
52
+
53
+ # Tool-generated region markers on their own line, e.g. <!-- spec:toc:begin --> / <!-- spec:toc:end -->
54
+ MARKER = /^[ \t]*<!--[ \t]*[a-z][a-z0-9-]*(?::[a-z0-9._-]+)*:(?:begin|end)[ \t]*-->[ \t]*$/
55
+
56
+ # 1c. forbidden syntax outside code
57
+ def forbidden(ctx, f, code, html_free)
58
+ r = ctx.rel(f)
59
+ ctx.errs << "#{r}: HTML comment <!-- --> (hidden on GitHub, emitted by kramdown); remove it" if code.gsub(MARKER, "").include?("<!--")
60
+ html_free.each_line.with_index(1) do |l, n|
61
+ linkless = l.gsub(/\]\([^)]*\)/, "]()").gsub(/<(https?:|mailto:)[^>]*>|<[^@\s>]+@[^>\s]+>/, "<>").sub(/^ {0,3}\[[^\]]+\]:\s*\S+/, "")
62
+ why = case l
63
+ when /<\/?[a-zA-Z][\w-]*[\s>\/]/ then "raw HTML (not portable: GitHub renders some tags, strips others; kramdown passes all through)"
64
+ when /\{%|\{\{/ then "Liquid"
65
+ when /\{:/ then "kramdown IAL/ALD"
66
+ when /\{#[\w-]+\}/ then "{#id} header id"
67
+ when /^:\s/ then "definition list"
68
+ when /^\*\[[^\]]+\]:/ then "abbreviation definition *[X]: (kramdown-only)"
69
+ when /^\s*([-*+]|\d+\.|\|).*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i, /^\s*(>\s*){2,}\[!/ then NESTED_ALERT
70
+ end
71
+ why ||= "bare URL/e-mail autolink (kramdown does not link it); write <https://…> or [text](url)" if linkless =~ %r{(?<![<\w/@.])(https?://|www\.)\S|(?<![<\w.+-])[\w.+-]+@[\w-]+\.[a-z]{2,}}i
72
+ ctx.errs << "#{r}:#{n}: #{why}" if why
73
+ end
74
+ end
75
+
76
+ # Section headings: only column-0 '## ' starts a section; returns how many there are.
77
+ def headings(ctx, f, html_free)
78
+ h2 = 0
79
+ prev = ""
80
+ list = false # inside a list item (last column-0 block started with a list marker)
81
+ html_free.each_line.with_index(1) do |l, n|
82
+ list = l =~ /^([-*+]|\d+[.)])\s/ ? true : false if l =~ /^\S/
83
+ msg = case l
84
+ when /^##( |$)/ then (h2 += 1; nil)
85
+ when /^\s+##( |$)/
86
+ if list then "'##' inside a list item is not a section (sections split only on column-0 '## '); use '###' or move it out of the list"
87
+ elsif l =~ /^ {1,3}#/ then "indented '##' (kramdown: paragraph, GitHub: heading); start at column 0"
88
+ end
89
+ when /^\s+> ?\[!\w+\]/ then list ? NESTED_ALERT : nil
90
+ when /^\s*(>\s*)+##( |$)/ then "'##' inside a quote/alert is not a section (sections split only on column-0 '## '; GitHub would show an h2 inside the box); use '###' or bold text"
91
+ when /^ {0,3}-+\s*$/
92
+ if prev =~ /\S/ && prev !~ /^\s*([-*+>|#]|\d+\.)/
93
+ n -= 1
94
+ "setext heading (text underlined with ---) is not allowed; use '## '"
95
+ end
96
+ end
97
+ ctx.errs << "#{ctx.rel(f)}:#{n}: #{msg}" if msg
98
+ prev = l
99
+ end
100
+ h2
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # Rule 2: badge vocabularies in code spans (status:, tag:, kbd:; since: is rejected before 1.1) and image alt text.
6
+ module PageValues
7
+ STATUS = %w[available partial planned].freeze
8
+
9
+ module_function
10
+
11
+ def call(ctx, f, b)
12
+ errs, r = ctx.errs, ctx.rel(f)
13
+ text = Markdown.strip_fences(b)
14
+ text.scan(/`status:([^`\s]*)`/) do |(v)|
15
+ errs << "#{r}: unregistered status value `status:#{v}` (allowed: #{STATUS.join('/')})" unless STATUS.include?(v)
16
+ end
17
+ text.scan(/`tag:([^`]*)`/) do |(v)|
18
+ errs << "#{r}: `tag:#{v}` badge value must be one token [A-Za-z0-9._-]" unless v =~ /\A[A-Za-z0-9._-]+\z/
19
+ end
20
+ text.scan(/`since:([^`]*)`/) do |(v)|
21
+ errs << "#{r}: `since:#{v}` is not supported before 1.1 (the API is not released yet); remove it"
22
+ end
23
+ text.scan(/`kbd:([^`]*)`/) do |(v)|
24
+ errs << "#{r}: `kbd:#{v}` must be keys joined by '+' without spaces (e.g. `kbd:Ctrl+K`)" unless v =~ /\A[^\s+]+(\+[^\s+]+)*\z/
25
+ end
26
+ errs << "#{r}: image without alt text (![](...)); write ![description](...)" if text.gsub(/`[^`\n]*`/, "") =~ /!\[\s*\]\(/
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "page_syntax"
4
+ require_relative "page_values"
5
+ require_relative "component_rules"
6
+ require_relative "page_sections"
7
+ require_relative "a11y/content"
8
+ require_relative "a11y/new_window"
9
+
10
+ module Modootoday
11
+ module Check
12
+ # Per-page Markdown rules, run once per content page in this order.
13
+ module Pages
14
+ module_function
15
+
16
+ def call(ctx)
17
+ ctx.mds.each do |f|
18
+ text = File.read(f)
19
+ b = Markdown.body(text)
20
+ code = Markdown.strip_code(b)
21
+ h2 = PageSyntax.call(ctx, f, text, b, code)
22
+ PageValues.call(ctx, f, b)
23
+ ComponentRules.inline(ctx, ctx.rel(f), b)
24
+ PageSections.call(ctx, f, text, b, code, h2)
25
+ A11yContent.call(ctx, f, Markdown.strip_comments(code))
26
+ NewWindow.call(ctx, f, text, Markdown.strip_comments(code))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slots"
4
+
5
+ module Modootoday
6
+ module Check
7
+ # _data/components.yml: the registry's own shape (keys, slot rules, parts against api.md)
8
+ # and the i18n keys it declares.
9
+ module Registry
10
+ REG_KEYS = %w[params i18n slots parts].freeze
11
+ RULE_KEYS = %w[required min max].freeze
12
+
13
+ module_function
14
+
15
+ def call(ctx)
16
+ errs = ctx.errs
17
+ api_md = "#{ctx.src}/api.md"
18
+ api_codes = File.exist?(api_md) && (tbl = File.read(api_md)[/^## Component structure\n.*?(?=^## )/m]) ? tbl.scan(/`([^`]+)`/).flatten.map { |x| x.gsub("\\|", "|") } : nil
19
+ (ctx.comps.is_a?(Hash) ? ctx.comps : {}).each do |c, spec|
20
+ where = "_data/components.yml: #{c}"
21
+ next if spec.nil?
22
+ next errs << "#{where} must be a map (#{REG_KEYS.join(', ')})" unless spec.is_a?(Hash)
23
+ (spec.keys - REG_KEYS).each { |k| errs << "#{where}: unknown key '#{k}' (known: #{REG_KEYS.join(', ')})" }
24
+ slots(errs, where, spec["slots"]) if spec.key?("slots")
25
+ errs << "#{where}.params must be a map of param name -> {allowed, required}" if spec.key?("params") && !spec["params"].is_a?(Hash)
26
+ parts(errs, where, spec["parts"], api_codes) if spec.key?("parts")
27
+ end
28
+ end
29
+
30
+ def slots(errs, where, slots)
31
+ forms = slots.is_a?(Array) ? slots : [slots]
32
+ errs << "#{where}.slots: a list of alternative forms needs at least 2 maps" if slots.is_a?(Array) && forms.size < 2
33
+ forms.each_with_index do |form, fi|
34
+ fw = slots.is_a?(Array) ? "#{where}.slots[#{fi}]" : "#{where}.slots"
35
+ next errs << "#{fw} must be a map of slot name -> {required, min, max}" unless form.is_a?(Hash)
36
+ form.each do |name, rule|
37
+ next errs << "#{fw}: unknown slot '#{name}' (known: #{Slots.names.join(', ')})" unless Slots.known?(name)
38
+ next if rule.nil?
39
+ next errs << "#{fw}.#{name} must be a map of required, min, max" unless rule.is_a?(Hash)
40
+ (rule.keys - RULE_KEYS).each { |k| errs << "#{fw}.#{name}: unknown key '#{k}' (known: #{RULE_KEYS.join(', ')})" }
41
+ errs << "#{fw}.#{name}.required must be true or false" if rule.key?("required") && ![true, false].include?(rule["required"])
42
+ %w[min max].each { |k| errs << "#{fw}.#{name}.#{k} must be a whole number >= 0" if rule.key?(k) && !(rule[k].is_a?(Integer) && rule[k] >= 0) }
43
+ mn = rule["min"].is_a?(Integer) ? rule["min"] : (rule["required"] == true ? 1 : nil)
44
+ errs << "#{fw}.#{name}: min #{mn} is greater than max #{rule['max']}" if mn && rule["max"].is_a?(Integer) && mn > rule["max"]
45
+ end
46
+ end
47
+ end
48
+
49
+ def parts(errs, where, parts, api_codes)
50
+ return errs << "#{where}.parts must be a list of CSS selectors" unless parts.is_a?(Array) && parts.all? { |x| x.is_a?(String) && !x.strip.empty? }
51
+ parts.each do |sel|
52
+ errs << "#{where}.parts: '#{sel}' does not look like a CSS selector" unless sel.match?(/\A[\w\s.#\[\]=|"'>+~:()*-]+\z/)
53
+ errs << "#{where}.parts: '#{sel}' is not in the Component structure table of api.md" if api_codes && !api_codes.include?(sel)
54
+ end
55
+ end
56
+
57
+ # Every i18n key a component declares exists in every _data/i18n/<lang>.yml or the theme catalog for that language.
58
+ def i18n_keys(ctx)
59
+ return unless ctx.comps
60
+ dig = ->(h, key) { key.split(".").reduce(h) { |a, k| a.is_a?(Hash) ? a[k] : nil } }
61
+ ctx.comps.each do |name, spec|
62
+ Array(spec && spec["i18n"]).each do |key|
63
+ ctx.i18n.each { |lang, h| ctx.errs << "_data/i18n/#{lang}.yml: missing key '#{key}' (component #{name})" if dig[h, key].nil? && !I18n.theme_keys(lang).include?(key) }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # The modootoday: settings in the site's _config.yml.
6
+ module SiteConfig
7
+ COMPONENTS = %w[callout badge hero cta card tabs code-copy nav sidebar page-actions pagination theme-toggle breadcrumb toc accordion steps figure timeline kbd search code footnotes progress embed banner filetree details faq params linkcards quote gallery pricing subscribe loading].freeze
8
+ SEMANTIC = %w[background foreground muted muted-foreground card card-foreground primary primary-foreground brand accent
9
+ accent-foreground border ring note tip important warning caution status-available status-partial
10
+ status-planned font-sans font-mono font-size line-height measure space radius].freeze
11
+
12
+ module_function
13
+
14
+ # twins: source needs Jekyll's raw copies of front-matter-less pages.
15
+ def source_mode(ctx)
16
+ return unless ctx.source_mode?
17
+ cfg = ctx.site_cfg
18
+ of = cfg["optional_front_matter"] || {}
19
+ ctx.errs << "_config.yml: modootoday.twins: source needs the raw copies; remove optional_front_matter.remove_originals" if of.is_a?(Hash) && of["remove_originals"] == true
20
+ ctx.errs << "_config.yml: modootoday.twins: source conflicts with require_front_matter: true" if cfg["require_front_matter"] == true
21
+ end
22
+
23
+ def call(ctx)
24
+ mtc, errs = ctx.mtc, ctx.errs
25
+ return errs << "_config.yml: modootoday: must be a map" if mtc && !mtc.is_a?(Hash)
26
+ return unless mtc.is_a?(Hash)
27
+ enums(mtc, errs)
28
+ if mtc.key?("archive")
29
+ a = mtc["archive"]
30
+ if !a.is_a?(Hash) || !(a.keys - %w[tags categories]).empty?
31
+ errs << "_config.yml: modootoday.archive must be a map with tags: and/or categories: (archive page URLs)"
32
+ else
33
+ a.each { |k, v| errs << "_config.yml: modootoday.archive.#{k} must be a site path like /#{k}/ (starts and ends with /)" unless v.to_s =~ %r{\A/(\S*/)?\z} }
34
+ end
35
+ end
36
+ errs << "_config.yml: modootoday.edit_url must be an http(s) URL ending in / (page.path is appended)" if mtc.key?("edit_url") && mtc["edit_url"].to_s !~ %r{\Ahttps?://\S+/\z}
37
+ ctx.warns << "_config.yml: modootoday.feed: true links /feed.xml; add jekyll-feed to plugins" if mtc["feed"] == true && !Array(ctx.site_cfg["plugins"]).map(&:to_s).include?("jekyll-feed")
38
+ %w[css_layers badges seo hreflang feed page_actions back_to_top loading].each { |k| errs << "_config.yml: modootoday.#{k} must be true or false" if mtc.key?(k) && ![true, false].include?(mtc[k]) }
39
+ errs << "_config.yml: modootoday.css must be split or bundle" if mtc.key?("css") && !%w[split bundle].include?(mtc["css"])
40
+ errs << "_config.yml: modootoday.banner must be a map with text: (and optional url:, link:)" if mtc.key?("banner") && !(mtc["banner"].is_a?(Hash) && mtc["banner"]["text"])
41
+ errs << "_config.yml: modootoday.sidebar must be auto, true or false" if mtc.key?("sidebar") && ![true, false, "auto"].include?(mtc["sidebar"])
42
+ if mtc.key?("components")
43
+ list = mtc["components"].is_a?(Array) ? mtc["components"] : (errs << "_config.yml: modootoday.components must be a list"; [])
44
+ (list.map(&:to_s) - COMPONENTS).each { |c| errs << "_config.yml: modootoday.components: unknown component '#{c}' (known: #{COMPONENTS.join(', ')})" }
45
+ end
46
+ tokens(ctx)
47
+ end
48
+
49
+ def enums(mtc, errs)
50
+ errs << "_config.yml: modootoday.twins must be true, false or source (got #{mtc['twins'].inspect})" if mtc.key?("twins") && ![true, false, "source"].include?(mtc["twins"])
51
+ errs << "_config.yml: modootoday.color_scheme must be auto, auto-dark, auto-light, light or dark (got #{mtc['color_scheme'].inspect})" if mtc.key?("color_scheme") && !%w[auto auto-dark auto-light light dark].include?(mtc["color_scheme"])
52
+ errs << "_config.yml: modootoday.csp must be strict or false (got #{mtc['csp'].inspect})" if mtc.key?("csp") && mtc["csp"] != "strict" && mtc["csp"] != false
53
+ errs << "_config.yml: modootoday.languages must be a list of language codes such as [en, ko]" if mtc.key?("languages") && !(mtc["languages"].is_a?(Array) && mtc["languages"].all? { |l| l.to_s =~ /\A[a-z]{2,3}(-[A-Za-z0-9]+)*\z/ })
54
+ end
55
+
56
+ def tokens(ctx)
57
+ mtc, errs = ctx.mtc, ctx.errs
58
+ %w[tokens tokens_dark].each do |k|
59
+ next unless mtc.key?(k)
60
+ next errs << "_config.yml: modootoday.#{k} must be a map" unless mtc[k].is_a?(Hash)
61
+ mtc[k].each do |name, val|
62
+ ctx.warns << "_config.yml: modootoday.#{k}.#{name}: not a semantic token (#{SEMANTIC.join(', ')})" unless SEMANTIC.include?(name.to_s)
63
+ v = val.to_s.strip
64
+ if v.empty? || v =~ /[;{}<>]/ || v !~ /\A[#\w\s.,%()"'+\-*\/]+\z/
65
+ errs << "_config.yml: modootoday.#{k}.#{name}: invalid CSS value #{val.inspect}"
66
+ elsif v.start_with?("#") && v !~ /\A#(\h{3}|\h{4}|\h{6}|\h{8})\z/
67
+ errs << "_config.yml: modootoday.#{k}.#{name}: invalid hex color #{val.inspect}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # Slot contract: named, stable parts of a section's Markdown. Detection is structural and
6
+ # mirrors what modootoday/markdown/sections.html and the component scripts consume; markup does not change.
7
+ module Slots
8
+ DETECT = {
9
+ "lead" => ->(m) { m.split(/\n\s*\n/).reject { |p| p.strip.empty? }.take_while { |p| p !~ /\A\s*([-*+]\s|\d+[.)]\s|###|```|~~~|>)/ }.count { |p| p =~ /\S/ && p !~ /\A\s*(\[[^\]]+\]\([^)]*\)\s*)+\z/ } },
10
+ "items" => ->(m) { m.scan(/^([-*+]|\d+[.)])\s/).size },
11
+ "panels" => ->(m) { m.scan(/^###(?: |$)/).size },
12
+ "actions" => ->(m) { m.scan(/^(?:\[[^\]\n]+\]\([^)\n]*\)[ \t]*)+$/).size },
13
+ "code" => ->(_m, raw) { n = 0; Markdown.strip_fences(raw) { n += 1 }; n },
14
+ "rows" => ->(m) { [m.scan(/^\s*\|.*\|\s*$/).size - 2, 0].max },
15
+ "quotes" => ->(m) { m.split(/\n\s*\n/).count { |p| p =~ /\A\s*>/ } },
16
+ "figures" => ->(m) { m.scan(/^!\[[^\]\n]+\]\([^)\n]+\)/).size },
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def known?(name) = DETECT.key?(name)
22
+ def names = DETECT.keys
23
+
24
+ # The problem with one slot of one section, or nil.
25
+ def count_problem(name, rule, md)
26
+ m, raw = md || ["", ""]
27
+ det = DETECT[name]
28
+ n = det.arity == 2 ? det.call(m, raw) : det.call(m)
29
+ rule = {} unless rule.is_a?(Hash)
30
+ min = rule["min"].is_a?(Integer) ? rule["min"] : (rule["required"] == true ? 1 : nil)
31
+ max = rule["max"].is_a?(Integer) ? rule["max"] : nil
32
+ return "slot '#{name}' has #{n} item(s), needs at least #{min}" if min && n < min
33
+ "slot '#{name}' has #{n} item(s), allows at most #{max}" if max && n > max
34
+ end
35
+
36
+ # slots: is a map (one form) or a list of maps (alternative forms; a section must satisfy one).
37
+ def check_section(errs, file, i, c, slots, md)
38
+ return if slots.nil?
39
+ forms = (slots.is_a?(Array) ? slots : [slots]).select { |f| f.is_a?(Hash) }
40
+ probs = forms.map { |form| form.select { |name, _| known?(name) }.filter_map { |name, rule| count_problem(name, rule, md) } }
41
+ return if probs.empty? || probs.any?(&:empty?)
42
+ if slots.is_a?(Array)
43
+ errs << "#{file}: sections[#{i}] #{c}: matches none of #{forms.size} slot forms (#{probs.each_with_index.map { |p, k| "form #{k + 1}: #{p.join(', ')}" }.join('; ')})"
44
+ else
45
+ probs[0].each { |p| errs << "#{file}: sections[#{i}] #{c}: #{p}" }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../schema/rules"
5
+
6
+ module Modootoday
7
+ module Check
8
+ # 6. Built site: schema.org JSON-LD. At most one application/ld+json block per page; valid JSON with the
9
+ # schema.org @context; every @type and property in the vocabulary; required properties per type
10
+ # (recommended ones are warnings with --schema-recommended); absolute URLs; ISO 8601 dates; unique @id; the page node agrees
11
+ # with og:url, og:title and og:image; inLanguage matches <html lang>.
12
+ module StructuredData
13
+ BLOCK = %r{<script type="application/ld\+json">(.*?)</script>}m
14
+ CONTEXT = %r{\Ahttps?://schema\.org/?\z}
15
+
16
+ module_function
17
+
18
+ def call(ctx)
19
+ Dir.glob("#{ctx.site}/**/*.html").sort.each { |f| page(ctx, f, File.read(f)) }
20
+ end
21
+
22
+ def page(ctx, f, html)
23
+ blocks = html.scan(BLOCK).flatten
24
+ return if blocks.empty?
25
+
26
+ ctx.errs << "#{f}: #{blocks.size} JSON-LD blocks (use one @graph per page)" if blocks.size > 1
27
+ blocks.each do |b|
28
+ data = begin
29
+ JSON.parse(b)
30
+ rescue JSON::ParserError => e
31
+ next ctx.errs << "#{f}: JSON-LD is not valid JSON (#{e.message[0, 80]})"
32
+ end
33
+ ctx.errs << "#{f}: JSON-LD @context is not https://schema.org" unless data["@context"].to_s =~ CONTEXT
34
+ nodes = data.key?("@graph") ? Array(data["@graph"]) : [data]
35
+ ids = nodes.filter_map { |n| n["@id"] if n.is_a?(Hash) }
36
+ ids.tally.each { |id, c| ctx.errs << "#{f}: JSON-LD @id #{id} used by #{c} nodes" if c > 1 }
37
+ nodes.each { |n| node(ctx, f, n, "@graph") }
38
+ meta(ctx, f, html, nodes)
39
+ end
40
+ end
41
+
42
+ # A node, then its nested nodes. Pure references ({"@id": ...}) need no @type.
43
+ def node(ctx, f, n, path)
44
+ return unless n.is_a?(Hash)
45
+
46
+ types = Array(n["@type"])
47
+ if types.empty?
48
+ ctx.errs << "#{f}: JSON-LD #{path} has no @type" unless n.keys == ["@id"] || path.end_with?("]")
49
+ end
50
+ types.each { |t| ctx.errs << "#{f}: JSON-LD @type #{t} is not in the schema.org vocabulary" unless Schema.type?(t) }
51
+ types.each { |t| required(ctx, f, n, t, path) }
52
+ n.each do |k, v|
53
+ next if %w[@context @type @graph].include?(k)
54
+
55
+ ctx.errs << "#{f}: JSON-LD #{path}.#{k} is not a schema.org property" unless k.start_with?("@") || Schema.property?(k)
56
+ values(ctx, f, k, v, "#{path}.#{k}")
57
+ end
58
+ end
59
+
60
+ def values(ctx, f, k, v, path)
61
+ Array(v).each_with_index do |x, i|
62
+ next node(ctx, f, x, v.is_a?(Array) ? "#{path}[#{i}]" : path) if x.is_a?(Hash)
63
+ next unless x.is_a?(String)
64
+
65
+ ctx.errs << "#{f}: JSON-LD #{path} is not an absolute URL (#{x[0, 60]})" if Schema::URL_KEYS.include?(k) && x !~ %r{\Ahttps?://}
66
+ ctx.errs << "#{f}: JSON-LD #{path} is not an ISO 8601 date (#{x[0, 40]})" if Schema::DATE_KEYS.include?(k) && x !~ Schema::ISO_8601
67
+ end
68
+ end
69
+
70
+ def required(ctx, f, n, type, path)
71
+ req, rec = Schema::RULES[type]
72
+ return unless req
73
+
74
+ has = ->(alt) { alt.split("|").any? { |p| !n[p].nil? && n[p] != "" && n[p] != [] } }
75
+ req.each { |p| ctx.errs << "#{f}: JSON-LD #{type} (#{path}) lacks required #{p.tr('|', '/')}" unless has.(p) }
76
+ return unless ctx.option?("--schema-recommended")
77
+
78
+ rec.each { |p| ctx.warns << "#{f}: JSON-LD #{type} (#{path}) lacks recommended #{p.tr('|', '/')}" unless has.(p) }
79
+ end
80
+
81
+ # The page node (#webpage) against Open Graph and <html lang>: one source of truth for title, URL and image.
82
+ def meta(ctx, f, html, nodes)
83
+ pg = nodes.find { |n| n.is_a?(Hash) && n["@id"].to_s.end_with?("#webpage") } or return
84
+ og = ->(p) { html[/<meta property="og:#{p}" content="([^"]*)"/, 1]&.then { |s| unescape(s) } }
85
+ { "url" => pg["url"], "title" => pg["name"], "image" => pg.dig("primaryImageOfPage", "url") }.each do |p, v|
86
+ o, v = og.(p)&.split&.join(" "), v&.split&.join(" ") if p == "title"
87
+ o ||= og.(p)
88
+ ctx.errs << "#{f}: JSON-LD #{p == 'title' ? 'name' : p} #{v.inspect} differs from og:#{p} #{o.inspect}" if o && v && o != v
89
+ end
90
+ lang = html[/<html[^>]*\blang="([^"]+)"/, 1]
91
+ ctx.errs << "#{f}: JSON-LD inLanguage #{pg['inLanguage']} differs from <html lang=\"#{lang}\">" if lang && pg["inLanguage"] && lang != pg["inLanguage"]
92
+ end
93
+
94
+ def unescape(s) = s.gsub("&quot;", '"').gsub("&#39;", "'").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # 5b/5c. Site sources: Sass portability on GitHub Pages (Ruby Sass 3.7: @use/@forward pass
6
+ # through silently, @supports selector() is a syntax error).
7
+ module Stylesheets
8
+ SASS_BAD = [[/^\s*@(use|forward)\b/, "@use/@forward (Ruby Sass on GitHub Pages copies it into the CSS untouched); use @import"],
9
+ [/@supports\s+(not\s+)?selector\(/, "@supports selector() (Ruby Sass syntax error on GitHub Pages)"]].freeze
10
+
11
+ module_function
12
+
13
+ def call(ctx)
14
+ sass(ctx)
15
+ end
16
+
17
+ def sass(ctx)
18
+ src = ctx.src
19
+ Dir.glob(["#{src}/_sass/**/*.scss", "#{src}/assets/**/*.scss"]).reject { |f| ctx.skipped?(f) }.sort.each do |f|
20
+ File.read(f).gsub(%r{/\*.*?\*/}m) { |c| "\n" * c.count("\n") }.each_line.with_index(1) do |l, n|
21
+ l = l.sub(%r{//.*}, "")
22
+ SASS_BAD.each { |re, why| ctx.errs << "#{ctx.rel(f)}:#{n}: #{why}" if l =~ re }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ module Check
5
+ # 7. --twins: every Markdown page (except README.md and `twin: false`) has /<page>/index.md
6
+ # (/index.md for index.md), byte-identical to its source body. It comes from a stub <name>.md.txt
7
+ # (layout: twin; works on GitHub Pages) or from the opt-in plugin (plugins: [jekyll-theme-modootoday]).
8
+ # With modootoday.twins: source, a page without front matter is its own twin at /<path>.md.
9
+ # llms.txt / llms-full.txt exist at / and at each /<lang>/; llms-full.txt contains every page body.
10
+ module Twins
11
+ HINT = "add a stub %s (front matter `layout: twin`, `twin_of`, `permalink`) or enable `plugins: [jekyll-theme-modootoday]` (not on classic GitHub Pages)"
12
+
13
+ module_function
14
+
15
+ def call(ctx)
16
+ return unless ctx.option?("--twins")
17
+ bodies = Hash.new { |h, k| h[k] = [] }
18
+ ctx.mds.each { |f| page(ctx, f, bodies) }
19
+ llms(ctx, bodies)
20
+ end
21
+
22
+ def page(ctx, f, bodies)
23
+ errs, site, rf = ctx.errs, ctx.site, ctx.rel(f)
24
+ stub = "#{f}.txt"
25
+ if File.exist?(stub)
26
+ st = File.read(stub)
27
+ meta = (Markdown.fm(st) rescue {})
28
+ errs << "#{ctx.rel(stub)}: stub must set layout: twin" unless meta["layout"] == "twin"
29
+ errs << "#{ctx.rel(stub)}: stub body must be empty" unless Markdown.body(st).strip.empty?
30
+ end
31
+ return if File.basename(f) == "README.md" || (Markdown.fm(File.read(f)) rescue {})["twin"] == false
32
+ return if rf.split("/").include?("_posts") # posts: dated URLs, listed by the archive; no twin (documented limit)
33
+ r = rf.delete_suffix(".md")
34
+ if ctx.source_mode? && File.read(f) !~ Markdown::FM
35
+ # front-matter-less mode: rendered page + raw copy of the whole source at its own path
36
+ errs << "#{rf}.txt: redundant stub in twins: source mode (the source is already the twin); delete it" if File.exist?(stub)
37
+ raw = "#{site}/#{rf}"
38
+ html = ["#{site}/#{r}.html", "#{site}/#{r}/index.html"]
39
+ bodies[ctx.lang_of(r).to_s] << [rf, File.read(f)]
40
+ errs << "#{rf}: no rendered page (#{html.map { |h| h.delete_prefix("#{site}/") }.join(' or ')}); is jekyll-optional-front-matter enabled? (local builds: list it in plugins:)" unless html.any? { |h| File.exist?(h) }
41
+ return errs << "#{rf}: raw copy missing at #{raw} (optional_front_matter.remove_originals must not be true)" unless File.exist?(raw)
42
+ return errs << "#{raw}: raw copy is not byte-identical to #{rf}" unless File.binread(raw) == File.binread(f)
43
+ return
44
+ end
45
+ out = File.basename(r) == "index" ? "#{site}/#{r}.md" : "#{site}/#{r}/index.md"
46
+ b = Markdown.body(File.read(f))
47
+ bodies[ctx.lang_of(r).to_s] << [rf, b]
48
+ return errs << "#{rf}: twin missing at #{out}: #{format(HINT, "#{rf}.txt")}" unless File.exist?(out)
49
+ errs << "#{out}: twin is not byte-identical to the body of #{rf}" unless File.binread(out) == b.b
50
+ end
51
+
52
+ def llms(ctx, bodies)
53
+ langs = [""] + ctx.i18n.keys.select { |l| Dir.exist?("#{ctx.src}/#{l}") }
54
+ langs.each do |l|
55
+ %w[llms.txt llms-full.txt].each do |n|
56
+ p = [ctx.site, l, n].reject(&:empty?).join("/")
57
+ next ctx.errs << "#{p}: missing (per-language llms file; stub with `layout: #{n.delete_suffix('.txt')}` or the plugin)" unless File.exist?(p)
58
+ txt = File.read(p)
59
+ if n == "llms.txt"
60
+ ctx.errs << "#{p}: must start with an H1 (llmstxt.org)" unless txt.start_with?("# ")
61
+ else
62
+ bodies[l].each { |(name, b)| ctx.errs << "#{p}: does not contain the body of #{name}" unless b.strip.empty? || txt.include?(b.strip) }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # md-components v1 checker (Ruby stdlib only; Nokogiri optional). This file only assembles the parts;
4
+ # each rule group lives in check/<name>.rb and reports into one shared Context.
5
+ require "yaml"
6
+ require "date"
7
+ require_relative "check/markdown"
8
+ require_relative "check/context"
9
+ require_relative "check/registry"
10
+ require_relative "check/pages"
11
+ require_relative "check/i18n"
12
+ require_relative "check/built_parts"
13
+ require_relative "check/build_output"
14
+ require_relative "check/structured_data"
15
+ require_relative "check/stylesheets"
16
+ require_relative "check/site_config"
17
+ require_relative "check/blog"
18
+ require_relative "check/github_pages"
19
+ require_relative "check/layouts"
20
+ require_relative "check/twins"
21
+ require_relative "check/gh_render"
22
+ require_relative "check/cli"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modootoday
4
+ # schema.org rules the checker applies to built JSON-LD: the vocabulary (vocab.txt, from script/schema-vocab),
5
+ # required properties per type (schema.org and Google Search structured data guidelines) and recommended ones.
6
+ module Schema
7
+ VOCAB_FILE = File.expand_path("vocab.txt", __dir__)
8
+ # Not schema.org properties, but documented by Google/schema.org Actions for SearchAction.
9
+ EXTRA_PROPS = %w[query-input].freeze
10
+ ARTICLE = %w[Article BlogPosting NewsArticle TechArticle Report ScholarlyArticle SocialMediaPosting].freeze
11
+ PAGE = %w[WebPage AboutPage CollectionPage ContactPage FAQPage ProfilePage SearchResultsPage ItemPage QAPage].freeze
12
+ # type => [required, recommended]; one alternative per entry ("a|b": either).
13
+ RULES = {
14
+ "WebSite" => [%w[name url], %w[inLanguage publisher]],
15
+ "Organization" => [%w[name], %w[url logo]],
16
+ "Person" => [%w[name], %w[url]],
17
+ "BreadcrumbList" => [%w[itemListElement], []],
18
+ "ListItem" => [%w[position], %w[name item|url]],
19
+ "ItemList" => [%w[itemListElement], []],
20
+ "JobPosting" => [%w[title description datePosted hiringOrganization jobLocation|applicantLocationRequirements], %w[employmentType validThrough baseSalary]],
21
+ "Event" => [%w[name startDate location], %w[endDate eventStatus image description organizer offers]],
22
+ "Course" => [%w[name description], %w[provider offers hasCourseInstance]],
23
+ "PodcastEpisode" => [%w[name], %w[partOfSeries datePublished associatedMedia|audio]],
24
+ "SoftwareApplication" => [%w[name], %w[offers applicationCategory operatingSystem]],
25
+ "Offer" => [%w[price|priceSpecification priceCurrency|priceSpecification], %w[availability url]],
26
+ "DefinedTermSet" => [%w[name], %w[hasDefinedTerm]],
27
+ "DefinedTerm" => [%w[name], %w[description]],
28
+ "Question" => [%w[name acceptedAnswer|suggestedAnswer], []],
29
+ "Answer" => [%w[text], []],
30
+ "SearchAction" => [%w[target query-input], []],
31
+ "ImageObject" => [%w[url|contentUrl], []],
32
+ }.merge(ARTICLE.to_h { |t| [t, [%w[headline], %w[datePublished dateModified author image]]] })
33
+ .merge(PAGE.to_h { |t| [t, [%w[name url], %w[inLanguage]]] })
34
+ .merge("FAQPage" => [%w[name url mainEntity], []], "ProfilePage" => [%w[name url], %w[mainEntity]]).freeze
35
+ URL_KEYS = %w[@id url item logo image sameAs contentUrl embedUrl urlTemplate applicationUrl downloadUrl installUrl].freeze
36
+ DATE_KEYS = %w[datePublished dateModified dateCreated datePosted validThrough startDate endDate uploadDate].freeze
37
+ ISO_8601 = /\A\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?\z/
38
+
39
+ module_function
40
+
41
+ def vocab
42
+ @vocab ||= File.readlines(VOCAB_FILE, chomp: true).reject { |l| l.start_with?("#") }
43
+ .each_with_object({ "t" => {}, "p" => {} }) { |l, h| k, n = l.split(" ", 2); h[k][n] = true }
44
+ end
45
+
46
+ def type?(name) = vocab["t"].key?(name)
47
+ def property?(name) = vocab["p"].key?(name) || EXTRA_PROPS.include?(name)
48
+ end
49
+ end