From e144d91055e66e28a0f51539c428cf4f659398a6 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Fri, 4 Sep 2026 19:54:59 +0200 Subject: [PATCH] Add Valibot documentation (1.4.2) https://valibot.dev/ --- assets/javascripts/news.json | 4 + lib/docs/scrapers/valibot.rb | 218 ++++++++++++++++++++++++++++ public/icons/docs/valibot/16.png | Bin 0 -> 845 bytes public/icons/docs/valibot/16@2x.png | Bin 0 -> 1196 bytes public/icons/docs/valibot/SOURCE | 1 + 5 files changed, 223 insertions(+) create mode 100644 lib/docs/scrapers/valibot.rb create mode 100644 public/icons/docs/valibot/16.png create mode 100644 public/icons/docs/valibot/16@2x.png create mode 100644 public/icons/docs/valibot/SOURCE diff --git a/assets/javascripts/news.json b/assets/javascripts/news.json index 06e4411595..b369dd257b 100644 --- a/assets/javascripts/news.json +++ b/assets/javascripts/news.json @@ -1,4 +1,8 @@ [ + [ + "2026-09-04", + "New documentation: Valibot" + ], [ "2026-08-15", "New documentations: Rack, pytest" diff --git a/lib/docs/scrapers/valibot.rb b/lib/docs/scrapers/valibot.rb new file mode 100644 index 0000000000..ef2af1e295 --- /dev/null +++ b/lib/docs/scrapers/valibot.rb @@ -0,0 +1,218 @@ +require 'pathname' + +module Docs + class Valibot < UrlScraper + self.name = 'Valibot' + self.slug = 'valibot' + self.type = 'simple' + self.release = '1.4.2' + self.base_url = 'https://valibot.dev/' + self.links = { + home: 'https://valibot.dev/', + code: 'https://github.com/open-circle/valibot' + } + + # https://github.com/open-circle/valibot/blob/main/LICENSE.md + options[:attribution] = <<-HTML + © Fabian Hiller
+ Licensed under the MIT License. + HTML + + def get_latest_version(opts) + get_npm_version('valibot', opts) + end + + # valibot.dev serves a Markdown version of every page at the same URL with a + # `.md` extension, and indexes all of them in llms.txt. Scraping those rather + # than the HTML pages saves us from stripping the framework noise Qwik leaves + # in the server-rendered markup (`` comments and `q:*` attributes on + # nearly every node), and llms.txt also tells us which section a page belongs + # to, which is what the entry types are built from. + INDEX_PATH = 'llms.txt' + + # The llms.txt sections to scrape. The blog is left out: those posts are + # release announcements and design write-ups, not documentation. + SECTIONS = ['Guides', 'API reference'].freeze + + # The guide that becomes the documentation's root page. + ROOT_SOURCE_PATH = 'guides/introduction' + + # A documentation page as listed in llms.txt. `source_path` is the path on + # valibot.dev, `path` the one used inside DevDocs (see #index_page). + Page = Struct.new(:name, :type, :path, :source_path) + + # `path` is either a DevDocs path or the path of the page on valibot.dev, + # which only differ for the API reference types (see #index_page). + def build_page(path) + path = path.delete_prefix('/') + page = pages_by_path[path] || pages_by_source_path[path] + raise "#{self.class.name}: #{path.inspect} isn't listed in #{INDEX_PATH}" if page.nil? + + response = request_one(source_url_for(page)) + result = render(page, body_of(response)) if process_response?(response) + yield result if block_given? + result + end + + def build_pages + pages_by_url = pages_by_source_path.values.index_by { |page| source_url_for(page) } + instrument 'running.scraper', urls: pages_by_url.keys + + request_all pages_by_url.keys do |response| + if process_response?(response) + yield render(pages_by_url[response.url.to_s], body_of(response)) + else + instrument 'ignore_response.scraper', response: response + end + nil # returning an Array would queue its values as additional URLs + end + end + + private + + # The pages are fetched as Markdown (`text/markdown`), which the inherited + # implementation rejects because it isn't an HTML content type. + def process_response?(response) + raise "Error status code (#{response.code}): #{response.url}" if response.error? + raise "Empty response body: #{response.url}" if response.blank? + response.success? + end + + # Typhoeus hands back binary strings, whereas the documentation is UTF-8. + def body_of(response) + response.body.dup.force_encoding(Encoding::UTF_8) + end + + def index_pages + @index_pages ||= parse_index(body_of(request_one(url_for(INDEX_PATH)))) + end + + def pages_by_path + @pages_by_path ||= index_pages.index_by(&:path) + end + + def pages_by_source_path + @pages_by_source_path ||= index_pages.index_by(&:source_path) + end + + def source_url_for(page) + url_for "#{page.source_path}.md" + end + + # llms.txt is a flat Markdown document: `## Section`, `### Subsection` and + # `- [Name](url.md)` lines, in that order. + INDEX_ENTRY_REGEXP = /\A- \[(?.+)\]\((?\S+\.md)\)/ + + def parse_index(body) + section = subsection = nil + + body.each_line.filter_map do |line| + case line + when /\A## (.+)/ + section, subsection = $1.strip, nil + nil + when /\A### (.+)/ + subsection = $1.strip + nil + when INDEX_ENTRY_REGEXP + name, url = $~[:name], $~[:url] + next unless SECTIONS.include?(section) && url.start_with?(base_url.to_s) + index_page name, section, subsection, url[base_url.to_s.length..].delete_suffix('.md') + end + end + end + + def index_page(name, section, subsection, source_path) + if section == 'Guides' + path = source_path == ROOT_SOURCE_PATH ? 'index' : source_path + # The guide subsections overlap with the API reference ones ("Schemas"), + # so they are prefixed to keep the two apart in the sidebar. + type = "Guides: #{subsection}" + else + # DevDocs paths are case-insensitive (see NormalizePathsFilter), but + # Valibot names most of its types after the function they belong to + # (`Enum` vs `enum`). Types therefore get their own subdirectory. + basename = File.basename(source_path).downcase + path = subsection == 'Types' ? "api/types/#{basename}" : "api/#{basename}" + type = subsection + end + + Page.new(name, type, path, source_path) + end + + def render(page, body) + output = markdown_renderer.render(strip_markdown_note(body)) + output = fix_code_blocks(output) + output = fix_links(output, page) + output << attribution_for(page) + + { path: page.path, store_path: "#{page.path}.html", output: output, entries: [entry_for(page)] } + end + + def entry_for(page) + # The root page is the documentation itself and has no name or type. + page.path == 'index' ? Entry.new(nil, 'index', nil) : Entry.new(page.name, page.path, page.type) + end + + # Every page opens with a note pointing at its HTML version and at llms.txt, + # which is meaningless inside DevDocs. + MARKDOWN_NOTE_REGEXP = /^> This document is the Markdown version of [^\n]+\n\n?/ + + def strip_markdown_note(body) + body.sub MARKDOWN_NOTE_REGEXP, '' + end + + # DevDocs highlights
 elements based on their data-language attribute.
+    def fix_code_blocks(html)
+      html.gsub(%r{
}) { %(
) }
+    end
+
+    def fix_links(html, page)
+      html.gsub(/href="(\/[^"]*)"/) { %(href="#{fix_link($1, page)}") }
+    end
+
+    def fix_link(href, page)
+      path, _, fragment = href.partition('#')
+      path = path.delete_prefix('/')
+
+      if (target = pages_by_source_path[path.delete_suffix('.md')])
+        path = relative_path_from(page.path, target.path)
+      else
+        # Anything that isn't scraped (the blog, the playground, the thesis PDF)
+        # is linked to the website instead.
+        path = File.join(base_url.to_s, path.sub(/\.md\z/, '/'))
+      end
+
+      fragment.empty? ? path : "#{path}##{fragment}"
+    end
+
+    def relative_path_from(path, other_path)
+      Pathname.new(other_path).relative_path_from(File.dirname(path)).to_s
+    end
+
+    # Mirrors AttributionFilter, which isn't in the pipeline because these pages
+    # never go through it. The link points at the HTML version of the page.
+    def attribution_for(page)
+      url = File.join(base_url.to_s, page.source_path, '/')
+      <<-HTML.strip_heredoc
+      
+

+ #{options[:attribution].strip_heredoc.delete "\n"}
+ #{url} +

+
+ HTML + end + + def markdown_renderer + @markdown_renderer ||= Redcarpet::Markdown.new( + Redcarpet::Render::HTML.new(with_toc_data: true), + autolink: true, + fenced_code_blocks: true, + no_intra_emphasis: true, + strikethrough: true, + tables: true + ) + end + end +end diff --git a/public/icons/docs/valibot/16.png b/public/icons/docs/valibot/16.png new file mode 100644 index 0000000000000000000000000000000000000000..4b26b54465489bdfd88989418ac04d908def4873 GIT binary patch literal 845 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1JbodG@}u0X;6 zAONI55J-bX8i1_-{~P|_0Fu$ULPGz)DE@z<_y4xu|F=co-WL78Z}H>*#Q#4g{&;Nu z|E}GS^HKk=DgFP^_Wx7c#c2%h_L!gPXFb!;u)mDqF}&Nz@aJmG{Uu6! z=6UU39DH<&%7uxH8{1Sr?a{b%xUe?U^xg)mx#g;bF&fi~)N(?E-)+~NSfDX6U!8$b zg@Hlk)n<+Ma+lr$?Vfy{v>@#XO+oW&^&5&k>x;eWfn6%H;9@R_m4YT|B_tX9XA3ZBAw{y`3jzG{g+YN5V*hOPmEe);lF5pqtEruv$u z`WhPQ+IniTDvAois%F9}W`c@_f(nL$vN{a10fG|h0ut(c;y|RvFQz6asw5zy%+D{! z$1g9!z$gL=X(I+k9eze2Lx+J;jUObU&cL9?z^K3nj35R@Mg|2&Mkxj$$sonRD9*qj z!^pr0QVs+MR#*bb;F2J}V4xTXFfp<4^6`nw$jHhnsDgo-n!28mk%^_HZA9YS#hdo- zyME)z%hzu|e*XUB$B*B?|A4?>82E<_4$X`^1vLJ#r;B4q#Vym52fdgLC0HK(oNPb4 zWc!-17Nss$?WP3*H9YYLw|{x#tI<>;WK(gmnSG^(_N}tXd7sQ}zeu-P@-P}mIaxLw zZ##VLnNq}k?av|&I?@qEZ&yldsGf-ZQ*(y%v8U}%UCqL%BRB7AZ(ck-%OF?x(AC3V zYmYh~H$VUWx4Lo9yd4)=wV63%KTJ|NS|4iAyJda&x~qS-hRaTK+gRgmr4{b=vucNp zu}q|fZ@0>#%O4*v6*#9~c5JSj+K=6}%3t`acFR@B^j}RozbnV gi~(#4j7^*eq|)pouQDu5S_KMJPgg&ebxsLQ0MM071poj5 literal 0 HcmV?d00001 diff --git a/public/icons/docs/valibot/16@2x.png b/public/icons/docs/valibot/16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fc7c53fe7d925f38db73271fba4d8334a307e761 GIT binary patch literal 1196 zcmY*XdrXse6#ccO!&l*}FsM*yDfRbBc@=F1RD56y1*JeO7SRb?0%puH98(~IR7|mz zM*u~QTGT=ynb-o(jVhvUicFvkt4I-i5m1@XIRWW|efvoE&rZ%g=bro5P3~1Ch@&Y> zJeB|eC@~^|1b5~_Eh6D6s6Ww(8zDy$9SO7s`a8TgCv2Ar@gauEF$|2G;CK(jCS&FX zgXRWNY%~;m9*SAZFcaTu;$y=Rw&4hDFapC)V^}rzso3_Z7<&{laB=Ct`K8146#IkV zS9Ro>c8;Oa(NIaAyBjonH^_d6H8-$-T<7;j=cqrowWo|^A5}c9XFsiD&v$L;zn1LE7KhBcs9jrOWx?*&wMF|G4Lu&y>Ox!I&UFALSrYR}8o>OZa3&ptj4x=X<| z<;tY9H>wqh%*!Jc+5Q#T91mqXt*ALa%|CB}obdF5jAR}oUAa0Xo4F&4DampR6LSOn zd;-H?F-o~l+A(+2FhSShBu0^_y~*VOZW8jLOs18)hm$d6@YjJ zP*MgNTcd6NOG8Q7I(E84#TDqXHBF z=mZFWz;s%OOu~YKBNOQ=+=M?!#5(|y=;}&G(V(C;eE$0N>o?&L2!)A~z5BAVs;aJN z2S&%or>3W8EI6$8|M1WMu~=cP0f6WcBiJI%8@gre&XjIJIzJT6yql_9qgp{+JRVu8 zq)|MwtI+I8Uy)mJTlSx8lzlHdqyM=2Y*o;*b3#_XVtE}Dcb)CtBfe<-qC~aq*QOaq zGC3)A@>qN1tBk($Jh`^sW~dEKTZi4KCs(@yQ7 zmo(MVx4ZLg-jvWu8LI`|$Qdqoj$Q>o@JK?aS5pl*)CIhrDj{27O^` z9PFu>)ppdL+`t~y$jUSSYAGw%f1^vGeO~(5B|o=7!zpZjdD1+ms`@#F{#mFhazJfq z3r}?3m^W!Lgn9O=?g^WDms-YNzaKi&a(yfn$sFOf-tMn`+!V>>8n?<=52`Y69&OzA zWH7>T!!f6?iz|{a>q;_=$CaUjv-6UWEkl|oTTN>$dQ=wXyd;6x#z7}ptq<3cAK`kraZ_)LETAlFdwWXD=P5c_`-iJQH z`^!NlSH6f!OcFWxk~_k_<+2G|Rj+ROZ@%;9WrSgu`I!Iq8ZJ+Eri1_Xs~V|Wg2PaV zqH=cn?!?BO)0OW(8V^5<2EPL)%|_3nCq*IZ_GZL6qA)kSuGM6-WO!1M-iAF#@=tZd zo^noz6-^cj$O!qcczdisBrlEyf5|f{I1BF=BNPj? Ik!i*M0x^^b%K!iX literal 0 HcmV?d00001 diff --git a/public/icons/docs/valibot/SOURCE b/public/icons/docs/valibot/SOURCE new file mode 100644 index 0000000000..22b4aab9c5 --- /dev/null +++ b/public/icons/docs/valibot/SOURCE @@ -0,0 +1 @@ +https://valibot.dev/icon-32px.png