From a2fc0e43c14c2cf38247fe3765b7fc62ac9af498 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:36 +0200 Subject: [PATCH 1/7] Let a file scraper unpack an archive outside its source directory The scrapers reading the MDN content repository need the data packages MDN generates its compatibility tables out of alongside the documents. --- lib/docs/core/scrapers/file_scraper.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/docs/core/scrapers/file_scraper.rb b/lib/docs/core/scrapers/file_scraper.rb index 07986b5106..205f87c542 100644 --- a/lib/docs/core/scrapers/file_scraper.rb +++ b/lib/docs/core/scrapers/file_scraper.rb @@ -199,13 +199,16 @@ def download_source end # Downloads an archive and moves it into #source_directory. Pass the - # subdirectory holding the documents when they aren't at the archive's root. - def download_and_extract(url, subdirectory = nil) + # subdirectory holding the documents when they aren't at the archive's root, + # and a destination to unpack somewhere else than #source_directory. Note + # that the destination is replaced, so the one holding the documents has to + # be unpacked before those nested inside it. + def download_and_extract(url, subdirectory = nil, destination: source_directory) instrument 'info.doc', msg: %(Downloading #{url}...) archive = Archive.download(url) - instrument 'info.doc', msg: %(Extracting the documentation files to "#{source_directory}"...) - Archive.unpack(archive, source_directory, directory: subdirectory) + instrument 'info.doc', msg: %(Extracting the documentation files to "#{destination}"...) + Archive.unpack(archive, destination, directory: subdirectory) ensure FileUtils.rm_f(archive) if archive end From 797f59908029ef6949f30c4d96fc0e57743d47c4 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:41 +0200 Subject: [PATCH 2/7] Add kramdown to read the markdown of the MDN content repository Redcarpet flattens the lists MDN writes its definition lists as, and reads two consecutive GitHub alerts as a single blockquote. kramdown's GFM parser nests both the way CommonMark does. --- Gemfile | 2 ++ Gemfile.lock | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/Gemfile b/Gemfile index 341849522e..8940f4f521 100644 --- a/Gemfile +++ b/Gemfile @@ -35,6 +35,8 @@ group :development do end group :docs do + gem 'kramdown' + gem 'kramdown-parser-gfm' gem 'redcarpet' end diff --git a/Gemfile.lock b/Gemfile.lock index a9e51e34b3..20eea3352a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -134,6 +134,10 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.19.4) + kramdown (2.5.2) + rexml (>= 3.4.4) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) logger (1.7.0) loofah (2.25.1) crass (~> 1.0.2) @@ -311,6 +315,8 @@ DEPENDENCIES html-pipeline (~> 2.14) image_optim image_optim_pack + kramdown + kramdown-parser-gfm minitest newrelic_rpm nokogiri @@ -390,6 +396,8 @@ CHECKSUMS io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa + kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 From 2da2577840a7fcb5c53aa45afe734f30d36a1c59 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:41 +0200 Subject: [PATCH 3/7] Add a scraper base for the MDN documentations built from git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MdnGit reads the markdown of https://github.com/mdn/content rather than the pages developer.mozilla.org renders out of it, which are the same documents with their sidebars, compatibility tables and specifications filled in. It downloads the content repository and the packages MDN builds those sections out of — browser-compat-data, web-features, web-specs and mdn-data — and expands the KumaScript macro calls its authors leave in the markdown itself. Every page is known upfront, so nothing is crawled and no request is sent while a documentation is built. Mdn, which crawls, stays behind for the documentations that haven't moved over. --- lib/docs/filters/mdn_git/clean_html.rb | 155 ++++++++++ lib/docs/filters/mdn_git/macros.rb | 45 +++ lib/docs/mdn_content/css.rb | 185 ++++++++++++ lib/docs/mdn_content/data.rb | 185 ++++++++++++ lib/docs/mdn_content/generator.rb | 259 ++++++++++++++++ lib/docs/mdn_content/macros.rb | 400 +++++++++++++++++++++++++ lib/docs/mdn_content/markdown.rb | 88 ++++++ lib/docs/mdn_content/pages.rb | 149 +++++++++ lib/docs/scrapers/mdn/mdn_git.rb | 147 +++++++++ 9 files changed, 1613 insertions(+) create mode 100644 lib/docs/filters/mdn_git/clean_html.rb create mode 100644 lib/docs/filters/mdn_git/macros.rb create mode 100644 lib/docs/mdn_content/css.rb create mode 100644 lib/docs/mdn_content/data.rb create mode 100644 lib/docs/mdn_content/generator.rb create mode 100644 lib/docs/mdn_content/macros.rb create mode 100644 lib/docs/mdn_content/markdown.rb create mode 100644 lib/docs/mdn_content/pages.rb create mode 100644 lib/docs/scrapers/mdn/mdn_git.rb diff --git a/lib/docs/filters/mdn_git/clean_html.rb b/lib/docs/filters/mdn_git/clean_html.rb new file mode 100644 index 0000000000..3ed7bb3238 --- /dev/null +++ b/lib/docs/filters/mdn_git/clean_html.rb @@ -0,0 +1,155 @@ +module Docs + class MdnGit + # Turns the HTML of the rendered markdown into the HTML MDN publishes: the + # definition lists and the note cards its authors write in markdown, and + # the ids its pages link their sections by. + class CleanHtmlFilter < Filter + # MDN's h1 carries the title of the page, which nothing links to. + SECTIONS = 'h2, h3, h4, h5, h6, dt' + + DEFINITION = ': ' + + # https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts + ALERTS = { + 'NOTE' => ['note', 'Note'], + 'WARNING' => ['warning', 'Warning'], + 'IMPORTANT' => ['note', 'Important'], + 'TIP' => ['note', 'Tip'], + 'CAUTION' => ['warning', 'Caution'] + } + + ALERT = /\A\s*\[!([A-Z]+)\]\s*/ + + def call + definition_lists + note_cards + code_blocks + section_ids + tables + empty_paragraphs + doc + end + + private + + # MDN writes its definition lists as lists whose every item is a term + # followed by a one-item list holding the definition, prefixed with a + # colon. See markdown/m2h/handlers/dl.ts in https://github.com/mdn/yari. + def definition_lists + # Innermost first, so that a nested list is a
by the time the one + # around it is looked at and can no longer be taken for a definition. + css('ul').to_a.reverse_each do |list| + next unless definition_list?(list) + list.name = 'dl' + list.element_children.each { |item| replace_with_definition(item) } + end + end + + def definition_list?(list) + items = list.element_children + items.any? && items.all? do |item| + definitions = item.element_children.last + next false unless definitions && definitions.name == 'ul' && item.children.size > 1 + next false unless definitions.element_children.size == 1 + definition?(definitions.element_children.first) + end + end + + def definition?(item) + leading_text(item).to_s.lstrip.start_with?(DEFINITION) + end + + def replace_with_definition(item) + definitions = item.element_children.last + definition = definitions.element_children.first + + term = Nokogiri::XML::Node.new('dt', item.document) + item.children.to_a.take_while { |child| child != definitions }.each { |child| term << child } + + described = Nokogiri::XML::Node.new('dd', item.document) + definition.children.to_a.each { |child| described << child } + remove_definition_prefix described + + item.add_previous_sibling term + item.replace described + end + + def remove_definition_prefix(node) + return unless (text = leading_text_node(node)) + text.content = text.content.lstrip.delete_prefix(DEFINITION) + end + + def leading_text(node) + leading_text_node(node)&.content + end + + def leading_text_node(node) + node.xpath('.//text()').find { |text| text.content.present? && !text.content.strip.empty? } + end + + # GitHub's alerts, which MDN renders as its note cards. + def note_cards + css('blockquote').each do |quote| + next unless (text = leading_text_node(quote)) + next unless (alert = text.content.match(ALERT)) + next unless (name, title = ALERTS[alert[1]]) + + text.content = text.content.sub(ALERT, '') + (quote.at_css('p') || quote).prepend_child %(#{title}: ) + + quote.name = 'div' + quote['class'] = "notecard #{name}" + end + end + + def code_blocks + css('pre > code').each do |code| + pre = code.parent + language = code['class'].to_s[/language-(\S+)/, 1] + pre['data-language'] = language if language + pre.content = code.content + end + end + + # MDN gives every heading and every term an id, which is what the links + # to a section of a page point at. See kumascript/src/api/util.ts in + # https://github.com/mdn/yari. + def section_ids + taken = Set.new + + css(SECTIONS).each do |node| + term = node.name == 'dt' + id = node['id']&.downcase + + if id.blank? + # A term can be followed by a badge, which isn't part of its name. + text = term ? node.element_children.first&.content || node.content : node.content + id = MdnContent.slugify(text) + id = "#{id}_#{taken.size}" if id.blank? + id = (2..).lazy.map { |count| "#{id}_#{count}" }.find { |candidate| !taken.include?(candidate) } if taken.include?(id) + end + + taken << id + node['id'] = id + + next unless term + first = node.element_children.first + next if first.nil? || first.name == 'a' || first.at_css('a') + first.replace %(#{first.to_html}) + end + end + + def tables + css('table').each do |table| + table.before %(
) + table.previous_element << table + end + end + + # Left behind by the macros that don't render to anything, e.g. {{JSRef}}. + def empty_paragraphs + css('p').each { |node| node.remove if node.content.strip.empty? && node.element_children.empty? } + end + end + end +end diff --git a/lib/docs/filters/mdn_git/macros.rb b/lib/docs/filters/mdn_git/macros.rb new file mode 100644 index 0000000000..220f65f208 --- /dev/null +++ b/lib/docs/filters/mdn_git/macros.rb @@ -0,0 +1,45 @@ +module Docs + class MdnGit + # Expands the KumaScript macro calls MDN leaves in its markdown. + class MacrosFilter < Filter + # A macro can expand into another one: the tables MDN builds out of its + # own data are written with the same cross-references as its prose. Two + # rounds are enough for those, the third is a stop. + PASSES = 3 + + def call + macros = MdnContent::Macros.new context[:page], context[:pages], context[:generator] + PASSES.times { break unless expand(macros) } + doc + end + + private + + def expand(macros) + expanded = false + + # A macro call in a code block is quoted rather than called, as on the + # page documenting the "Regular expression syntax error". The calls in + # the code that runs through the prose are expanded like any other. + xpath('.//text()[not(ancestor::pre)]').each do |node| + next unless macros.expand?(node.content) + html = macros.expand(node.content) + node = paragraph_of(node) if macros.block?(html) + html.empty? ? node.remove : node.replace(html) + expanded = true + end + + expanded + end + + # The paragraph a macro that renders to a block of its own sits in, which + # it replaces instead of nesting itself into. + def paragraph_of(node) + parent = node.parent + return node unless parent&.name == 'p' + return node unless parent.children.all? { |child| child == node || (child.text? && child.content.strip.empty?) } + parent + end + end + end +end diff --git a/lib/docs/mdn_content/css.rb b/lib/docs/mdn_content/css.rb new file mode 100644 index 0000000000..cc14197fe3 --- /dev/null +++ b/lib/docs/mdn_content/css.rb @@ -0,0 +1,185 @@ +require 'cgi' + +module Docs + module MdnContent + # The two sections MDN generates out of mdn-data on the pages of the CSS + # reference: the formal syntax of a property, type, function or at-rule, + # and the table of its characteristics below it. + # + # MDN expands and aligns the syntax with css-tree; this prints the same + # definitions, one alternative per line, without the alignment and without + # linking every multiplier to the page explaining it. See + # kumascript/src/lib/css-syntax.ts in https://github.com/mdn/yari. + class Css + REFERENCE = '/en-US/docs/Web/CSS/Reference' + + # How deep the definitions of the types a syntax refers to are followed. + # MDN stops when it runs out of definitions; the limit is a guard against + # the types that refer back to each other. + DEPTH = 10 + + # The rows of the characteristics table, and the order MDN puts them in. + CHARACTERISTICS = { + 'initial' => 'Initial value', + 'appliesto' => 'Applies to', + 'inherited' => 'Inherited', + 'percentages' => 'Percentages', + 'computed' => 'Computed value', + 'animationType' => 'Animation type' + }.freeze + + # A reference to the definition of a type, e.g. or + # . The references to a property are quoted — + # <'animation-name'> — which is why they don't match. + TYPE = /<([\w-]+(?:\(\))?)(?:\s*\[[^\]]*\])?>/ + + # The same reference to a property, once escaped. + PROPERTY = /<'([\w-]+)'>/ + + # The combinators a definition is broken into lines on, outside of any + # brackets. + COMBINATOR = /\s(\|\||&&|\||\s)\s/ + + def initialize(data) + @data = data + end + + # The formal syntax of the thing a page documents, followed by the + # definitions of the types it's made of. + def syntax(page) + name, syntax = definition(page) + return if syntax.blank? + + blocks = [[name, syntax]] + seen = [name] + pending = [syntax] + + DEPTH.times do + pending = pending.flat_map { |value| references(value) }.uniq + pending -= seen + pending = pending.filter_map do |reference| + next unless (value = @data.css_syntax(reference)) + seen << reference + blocks << ["<#{reference}>", value] + value + end + break if pending.empty? + end + + %(
#{blocks.map { |block| render(*block) }.join("\n")}
) + end + + def raw_syntax(syntax) + %(
#{escape syntax.delete('`')}
) + end + + # The table of the characteristics of a property or an at-rule + # descriptor, e.g. whether it's inherited and how it animates. + def info(page, name = nil, at_rule = nil) + return unless (entry = characteristics(page, name, at_rule)) + + rows = CHARACTERISTICS.filter_map do |key, label| + value = entry[key] + next if value.nil? || value == '' || (key == 'percentages' && value == 'no') + %(#{label}#{value_html value}) + end + + return if rows.empty? + %(#{rows.join}
) + end + + private + + # The name a page documents and the syntax of its definition, both of + # which depend on what kind of page it is. + def definition(page) + name = page.slug.split('/').last.downcase + + case page.page_type + when 'css-property', 'css-shorthand-property' + [name, @data.css('properties', name)&.fetch('syntax', nil)] + when 'css-type' + name = name.delete_suffix('_value') + [+"<#{name}>", @data.css_syntax(name) || @data.css('types', name)&.fetch('syntax', nil)] + when 'css-function' + [+"<#{name}()>", @data.css_syntax("#{name}()") || @data.css('functions', "#{name}()")&.fetch('syntax', nil)] + when 'css-at-rule' + [+"@#{name}", @data.css('at-rules', "@#{name}")&.fetch('syntax', nil)] + when 'css-at-rule-descriptor' + [name, descriptor(page, name)&.fetch('syntax', nil)] + else + [name, nil] + end + end + + def characteristics(page, name, at_rule) + name ||= page.slug.split('/').last.downcase + + if at_rule.present? || page.page_type == 'css-at-rule-descriptor' + descriptor page, name, at_rule + else + @data.css 'properties', name + end + end + + # An at-rule descriptor is documented one level below its at-rule. + def descriptor(page, name, at_rule = nil) + at_rule ||= "@#{page.slug.split('/')[-2].to_s.downcase}" + at_rule = "@#{at_rule}" unless at_rule.start_with?('@') + @data.css('at-rules', at_rule)&.dig('descriptors', name) + end + + def render(name, syntax) + lines = split(syntax).map { |line| " #{markup line}" } + "#{escape name} = \n#{lines.join("\n")}\n" + end + + # Breaks a definition on the combinators that aren't inside brackets, the + # way MDN lays it out. + def split(syntax) + lines = [''] + depth = 0 + + syntax.split(/(?<=[\s\[\]])|(?=[\[\]])/).each do |token| + depth += token.count('[') - token.count(']') + lines.last << token + lines << '' if depth.zero? && token.match?(/\A\s*(\|\||&&|\|)\s*\z/) + end + + lines.map(&:strip).reject(&:empty?) + end + + # The references to a property are the only ones worth a link: the types + # are spelled out right below. + def markup(line) + escape(line).gsub(PROPERTY) { %(<'#{$1}'>) } + end + + def references(syntax) + syntax.scan(TYPE).flatten + end + + # A shorthand takes its value from every property it stands for; the + # rest read as a string, or as one of the values mdn-data enumerates. + def value_html(value) + case value + when true then 'yes' + when false then 'no' + when Array + items = value.map { |name| "
  • #{link name}
  • " } + %(as each of the properties of the shorthand:
      #{items.join}
    ) + else + escape(@data.css_string(value) || value) + end + end + + def link(name) + %(#{escape name}) + end + + def escape(text) + CGI.escape_html text.to_s + end + end + end +end diff --git a/lib/docs/mdn_content/data.rb b/lib/docs/mdn_content/data.rb new file mode 100644 index 0000000000..9c58851c74 --- /dev/null +++ b/lib/docs/mdn_content/data.rb @@ -0,0 +1,185 @@ +require 'json' + +module Docs + module MdnContent + # The data MDN pulls in when it builds a page: the browser compatibility + # tables (@mdn/browser-compat-data), the titles of the specifications + # (web-specs) and the Baseline status of the features (web-features). + # + # The packages are pruned down to what's actually needed when they're + # downloaded (see .prepare). Their unpruned form weighs 25MB of JSON, which + # the scraper would otherwise read in every one of its forked workers. + class Data + COMPAT = 'compat.json' + SPECS = 'specs.json' + BASELINE = 'baseline.json' + CSS = 'css.json' + + def initialize(directory) + @directory = directory + end + + # The compatibility data of a feature, e.g. "javascript.builtins.Array.map". + def compat(query) + query.split('.').inject(compat_data) do |data, key| + break nil unless data.is_a?(Hash) + data[key] + end + end + + def browsers + compat_data['browsers'] ||= {} + end + + # The date a browser version was released, when it's known. + def release_date(browser, version) + browsers.dig(browser, 'releases', version, 'release_date') + end + + # The specification a URL points into. The URLs in browser-compat-data + # point at a section of the editor's draft more often than not, which is + # why the nightly urls are matched as well. + def spec(url) + specs.find do |spec| + url.start_with?(spec['url']) || + Array(spec['nightly_urls']).any? { |nightly| url.start_with?(nightly) } + end + end + + # The Baseline status of the feature a compatibility query documents. + def baseline(query) + baseline_data[query] + end + + # What MDN knows about a CSS property, type, function or at-rule beyond + # its own prose: its formal syntax and the table of its characteristics. + def css(kind, name) + css_data.dig(kind, name) + end + + # The definition of a CSS value type, e.g. "single-animation". + def css_syntax(name) + css_data.dig('syntaxes', name) + end + + # The English of the enumerated values of mdn-data, e.g. "allElements". + def css_string(key) + css_data.dig('l10n', key) + end + + private + + def compat_data + @compat_data ||= read(COMPAT) + end + + def specs + @specs ||= read(SPECS) + end + + def baseline_data + @baseline_data ||= read(BASELINE) + end + + def css_data + @css_data ||= File.exist?(File.join(@directory, CSS)) ? read(CSS) : {} + end + + def read(name) + JSON.parse File.read(File.join(@directory, name)) + end + + class << self + # Turns the three packages unpacked into directory into the files the + # scraper reads, and removes them. Only the sections of the + # compatibility data the documentation queries are kept. + def prepare(directory, namespaces) + prepare_compat directory, namespaces + prepare_specs directory + prepare_baseline directory, namespaces + prepare_css directory if Dir.exist?(File.join(directory, 'mdn-data')) + FileUtils.rm_rf File.join(directory, 'mdn-data') + end + + private + + def prepare_compat(directory, namespaces) + data = read_package(directory, 'browser-compat-data', 'data.json') + write directory, COMPAT, data.slice('browsers', *namespaces) + end + + def prepare_specs(directory) + specs = read_package(directory, 'web-specs', 'index.json') + + # Only the series' nightly url of the current specification is worth + # keeping: an older version would shadow the one MDN links to. + specs = specs.map do |spec| + nightly_urls = [] + nightly_urls.push(spec.dig('nightly', 'url'), *spec.dig('nightly', 'alternateUrls')) + nightly_urls << spec.dig('series', 'nightlyUrl') if spec['shortname'] == spec.dig('series', 'currentSpecification') + { 'url' => spec['url'], 'title' => spec['title'], 'nightly_urls' => nightly_urls.compact.uniq } + end + + write directory, SPECS, specs + end + + def prepare_baseline(directory, namespaces) + data = read_package(directory, 'web-features', 'data.json') + sections = namespaces.map { |namespace| "#{namespace}." } + baseline = {} + + data['features'].each_value do |feature| + status = feature['status'] + next unless status + + Array(feature['compat_features']).each do |query| + next unless sections.any? { |section| query.start_with?(section) } + baseline[query] = status.slice('baseline', 'baseline_low_date', 'baseline_high_date') + end + end + + write directory, BASELINE, baseline + end + + # What the CSS documentation reads out of mdn-data: the formal syntax + # of every property, type, function and at-rule, the characteristics + # MDN tabulates below it, and the English of the values it names. + CSS_FIELDS = %w(syntax initial appliesto inherited percentages computed animationType) + + def prepare_css(directory) + css = {} + + %w(properties types functions at-rules).each do |file| + data = read_package(directory, 'mdn-data', "css/#{file}.json", keep: true) + css[file] = data.transform_values { |value| slice_css value } + end + + syntaxes = read_package(directory, 'mdn-data', 'css/syntaxes.json', keep: true) + css['syntaxes'] = syntaxes.transform_values { |value| value['syntax'] } + + strings = read_package(directory, 'mdn-data', 'l10n/css.json', keep: true) + css['l10n'] = strings.transform_values { |translations| translations['en-US'] }.compact + + write directory, CSS, css + end + + def slice_css(value) + sliced = value.slice(*CSS_FIELDS) + descriptors = value['descriptors'] + sliced['descriptors'] = descriptors.transform_values { |descriptor| descriptor.slice(*CSS_FIELDS) } if descriptors + sliced + end + + def read_package(directory, name, file, keep: false) + JSON.parse File.read(File.join(directory, name, file)) + ensure + FileUtils.rm_rf File.join(directory, name) unless keep + end + + def write(directory, name, data) + File.write File.join(directory, name), JSON.generate(data) + end + end + end + end +end diff --git a/lib/docs/mdn_content/generator.rb b/lib/docs/mdn_content/generator.rb new file mode 100644 index 0000000000..ac5b364c25 --- /dev/null +++ b/lib/docs/mdn_content/generator.rb @@ -0,0 +1,259 @@ +require 'cgi' +require 'docs/mdn_content/css' + +module Docs + module MdnContent + # Builds the parts of a page that MDN generates out of data rather than out + # of markdown: the Baseline indicator, the status note cards, and the + # specifications and browser compatibility tables. + class Generator + BROWSERS = { + 'Desktop' => { + 'chrome' => 'Chrome', + 'edge' => 'Edge', + 'firefox' => 'Firefox', + 'opera' => 'Opera', + 'safari' => 'Safari' + }, + 'Mobile' => { + 'chrome_android' => 'Chrome Android', + 'firefox_android' => 'Firefox for Android', + 'opera_android' => 'Opera Android', + 'safari_ios' => 'Safari on iOS', + 'samsunginternet_android' => 'Samsung Internet', + 'webview_android' => 'WebView Android', + 'webview_ios' => 'WebView on iOS' + }, + 'Server' => { + 'bun' => 'Bun', + 'deno' => 'Deno', + 'nodejs' => 'Node.js' + } + }.freeze + + # https://github.com/mdn/yari/blob/main/client/src/lit/baseline-indicator.ts + BASELINE = { + 'high' => ['high', 'Widely available', <<~TEXT], + This feature is well established and works across many devices and + browser versions. + TEXT + 'low' => ['low', 'Newly available', <<~TEXT], + This feature works across the latest devices and browser versions. + This feature might not work in older devices or browser versions. + TEXT + 'false' => ['not', 'Limited availability', <<~TEXT] + This feature is not Baseline because it does not work in some of the + most widely-used browsers. + TEXT + }.freeze + + STATUSES = { + 'deprecated' => ['deprecated', 'Deprecated', <<~TEXT], + This feature is no longer recommended. Though some browsers might + still support it, it may have already been removed from the relevant + web standards, may be in the process of being dropped, or may only be + kept for compatibility purposes. Avoid using it, and update existing + code if possible; see the compatibility + table at the bottom of this page to guide your decision. Be aware + that this feature may cease to work at any time. + TEXT + 'experimental' => ['experimental', 'Experimental', <<~TEXT], + This is an experimental technology
    Check the + Browser compatibility table + carefully before using this in production. + TEXT + 'non-standard' => ['nonstandard', 'Non-standard', <<~TEXT] + This feature is non-standard and is not on a standards track. Do not + use it on production sites facing the Web: it will not work for every + user. There may also be large incompatibilities between implementations + and the behavior may change in the future. + TEXT + }.freeze + + def initialize(data) + @data = data + end + + # The sections MDN builds out of mdn-data, on the CSS pages only. + def css + @css ||= Css.new(@data) + end + + # Everything MDN puts above the content of a page: its title, how widely + # available the feature is, and whether it should be used at all. + def header(page) + html = +%(

    #{escape page.title}

    ) + html << baseline(page.browser_compat.first).to_s + page.status.each { |status| html << note_card(*STATUSES[status]) if STATUSES.key?(status) } + html + end + + def note_card(name, title, text) + %(

    #{title}: #{text.squish}

    ) + end + + def baseline(query) + return unless query && (status = @data.baseline(query)) + name, title, text = BASELINE[status['baseline'].to_s] + return unless name + + text = "#{text.squish} It's been available across browsers since #{month status['baseline_low_date']}." if status['baseline_low_date'] + + <<~HTML.squish +
    +
    Baseline #{title}
    +

    #{text.squish}

    +
    + HTML + end + + # The sections of browser-compat-data MDN lists the server-side runtimes + # for. The web APIs have data for them too, but no column. + SERVER_SECTIONS = %w(javascript webassembly) + + # The browser compatibility table of one or more features, e.g. + # "javascript.builtins.Array.map". + def compat(queries) + features = queries.flat_map { |query| compat_features(query) } + return if features.empty? + + browsers = BROWSERS + browsers = browsers.except('Server') unless queries.any? { |query| SERVER_SECTIONS.include?(query.split('.').first) } + ids = browsers.each_value.flat_map(&:keys) + + rows = features.map do |name, support| + cells = ids.map { |id| support_cell id, support[id] } + %(#{escape name}#{cells.join}) + end + + <<~HTML + + + #{['', *browsers.map { |platform, names| %() }].join} + #{['', *browsers.each_value.flat_map(&:values).map { |name| "" }].join} + + #{rows.join} +
    #{platform}
    #{name}
    + HTML + end + + # The specifications a feature is defined in. They're looked up in + # browser-compat-data unless the page names them itself. + def specifications(queries, urls) + urls = specification_urls(queries, urls) + return if urls.empty? + + rows = urls.map do |url| + title = specification_title(url) + fragment = url[/#(.+)\z/, 1] + title = %(#{escape title}
    # #{escape fragment}) if fragment + %(#{title}) + end + + <<~HTML + + + #{rows.join} +
    Specification
    + HTML + end + + # The names of the specifications a feature is defined in, which is how + # the entries filters tell one part of a documentation from another. + def specification_titles(queries, urls) + specification_urls(queries, urls).map { |url| specification_title url } + end + + private + + UNKNOWN_SPECIFICATION = 'Unknown specification' + + def specification_urls(queries, urls) + urls = queries.flat_map { |query| spec_urls @data.compat(query) } if urls.empty? + urls.uniq + end + + def specification_title(url) + @data.spec(url)&.fetch('title') || UNKNOWN_SPECIFICATION + end + + # The feature itself, when it has compatibility data of its own, followed + # by its subfeatures. A subfeature can go by the name of the feature — + # the constructor of Intl.Locale is javascript.builtins.Intl.Locale.Locale + # — hence the pairs rather than a hash. + def compat_features(query) + return [] unless (data = @data.compat(query)) + + features = [] + features << [query.split('.').last, data] if data.key?('__compat') + data.each { |key, value| features << [key, value] if value.is_a?(Hash) && value.key?('__compat') } + features.map { |name, feature| [name, feature['__compat']['support'] || {}] } + end + + # The first statement is the current one; the ones that follow it are the + # versions the feature was supported in before, under a prefix or a flag. + def support_cell(browser, support) + statements = Array.wrap(support) + return %(
    ?
    ) if statements.empty? + + versions = statements.map { |statement| support_version browser, statement } + %(#{versions.join}) + end + + def support_level(statement) + return 'no' if statement['version_added'] == false || statement['version_removed'] + return 'preview' if statement['version_added'] == 'preview' + return 'unknown' if statement['version_added'].nil? + return 'partial' if statement['partial_implementation'] || statement['flags'] + 'yes' + end + + def support_version(browser, statement) + added = statement['version_added'] + version = case added + when true then 'Yes' + when false then 'No' + when nil then '?' + else added + end + + version = "#{version}–#{statement['version_removed']}" if statement['version_removed'].is_a?(String) + + if added.is_a?(String) && (date = @data.release_date(browser, added)) + version = %(#{escape version}) + else + version = escape version + end + + notes = Array.wrap(statement['notes']) + return "
    #{version}
    " if notes.empty? + %(
    #{version}#{notes.join('
    ')}
    ) + end + + # The specification urls of a feature, or of its subfeatures when it has + # none of its own. + def spec_urls(data) + return [] unless data.is_a?(Hash) + return Array.wrap(data.dig('__compat', 'spec_url')) if data.key?('__compat') + data.each_value.flat_map { |value| spec_urls value } + end + + def month(date) + Date.parse(date).strftime('%B %Y') + rescue ArgumentError, TypeError + date + end + + def escape(text) + CGI.escape_html text.to_s + end + + # The urls of browser-compat-data point at the fragment the browsers gave + # a section of a specification, which is neither always ascii nor always + # escaped — %symbol.iterator% is a fragment, not an escape. + def escape_url(url) + url.gsub(/%(?![0-9A-Fa-f]{2})|[^\x21-\x7E]/) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join } + end + end + end +end diff --git a/lib/docs/mdn_content/macros.rb b/lib/docs/mdn_content/macros.rb new file mode 100644 index 0000000000..de7d2f416d --- /dev/null +++ b/lib/docs/mdn_content/macros.rb @@ -0,0 +1,400 @@ +require 'cgi' +require 'strscan' + +module Docs + module MdnContent + # Expands the KumaScript macro calls MDN leaves in its markdown, e.g. + # {{jsxref("Array")}} or {{Compat}}. Only the macros the JavaScript + # reference uses are implemented; the others are dropped. + # + # See https://github.com/mdn/yari/tree/main/kumascript/macros. + class Macros + DOCS = '/en-US/docs' + JAVASCRIPT = 'Web/JavaScript/Reference' + CSS = 'Web/CSS' + GLOBAL_OBJECTS = 'Global_Objects' + + # Where the CSS reference files its pages, which is what the names a + # cssxref goes by have to be looked for under. + CSS_SECTIONS = ['', 'Properties/', 'Values/', 'Selectors/', 'At-rules/'] + + # The macros whose expansion replaces the paragraph holding them rather + # than sitting inside it. + BLOCK = /\A<(?:table|div|details|pre|[uo]l|h\d)[\s>]/ + + CALL = /\{\{\s*([\w.-]+)/ + + def initialize(page, pages, generator) + @page = page + @pages = pages + @generator = generator + end + + def expand?(text) + text.include?('{{') + end + + # Replaces the macro calls in a string of text with their expansion, + # which is HTML — hence the escaping of everything around them. Anything + # that doesn't parse as a call is left alone. + def expand(text) + scanner = StringScanner.new(text) + result = +'' + + until scanner.eos? + result << CGI.escape_html(scanner.scan_until(/(?=\{\{)/).to_s) + break if scanner.eos? + + if (call = parse(scanner)) + result << call_macro(*call).to_s + else + result << CGI.escape_html(scanner.getch) + end + end + + result << CGI.escape_html(scanner.rest) + end + + def block?(html) + html.match? BLOCK + end + + private + + def parse(scanner) + position = scanner.pos + return revert(scanner, position) unless scanner.skip(/\{\{\s*/) && (name = scanner.scan(/[\w.-]+/)) + + arguments = [] + + if scanner.skip(/\s*\(/) + until scanner.skip(/\s*\)/) + argument = + if scanner.scan(/\s*"((?:[^"\\]|\\.)*)"/) || + scanner.scan(/\s*'((?:[^'\\]|\\.)*)'/) || + scanner.scan(/\s*`([^`]*)`/) + scanner[1] + else + scanner.scan(/[^,)]+/) + end + + # Anything else isn't a call, and would have the scanner stand + # still until the end of time. + return revert(scanner, position) if argument.nil? + + # The arguments are written the way they read, which for the names + # of the CSS types means <color> rather than . + arguments << CGI.unescape_html(argument.strip) + scanner.skip(/\s*,/) + end + end + + return revert(scanner, position) unless scanner.skip(/\s*\}\}/) + [name, arguments] + end + + def revert(scanner, position) + scanner.pos = position + nil + end + + def call_macro(name, arguments) + name = name.downcase.tr('-', '_') + return unless MACROS.include?(name) + send(name, *arguments) + rescue ArgumentError + nil + end + + # + # Cross-references + # + + def jsxref(api, display = nil, anchor = nil, plain = nil) + slug = api.sub('()', '').sub('.prototype.', '.') + slug = slug.sub('.', '/') if api.include?('.') && !api.include?('/') + content = content_for(display || api, plain) + + if documents?(JAVASCRIPT) + slug = "#{GLOBAL_OBJECTS}/#{slug}" if !@pages.include?(slug) && @pages.include?("#{GLOBAL_OBJECTS}/#{slug}") + return content unless @pages.include?(slug) + end + + link "#{DOCS}/#{JAVASCRIPT}/#{slug}#{fragment anchor}", content + end + + def domxref(api, display = nil, anchor = nil, plain = nil) + slug = api.tr(' ', '_').remove('()').gsub('.prototype.', '.').tr('.', '/').sub(/\A./, &:upcase) + display = display.presence || api + display = "#{display}.#{anchor}" if anchor.present? + link "#{DOCS}/Web/API/#{slug}#{fragment anchor}", content_for(display, plain) + end + + def glossary(term, display = nil, plain = nil) + link "#{DOCS}/Glossary/#{term.tr(' ', '_')}", content_for(display || term, plain || '1') + end + + # The CSS reference files a name under one of its sections and spells it + # the way the section does — the page of the "length" type is + # Values/length and goes by . Outside the CSS documentation, the + # link is the one MDN redirects from. + def cssxref(name, display = nil, anchor = nil) + page = css_page(name) if documents?(CSS) + display = display.presence || page&.short_title || name + slug = page ? page.slug : name.remove('()') + link "#{DOCS}/#{CSS}#{'/Reference' if page}/#{slug}#{fragment anchor}", content_for(display, nil) + end + + def css_page(name) + name = name.remove('()').remove(/\A<|>\z/) + CSS_SECTIONS.each do |section| + page = @pages["#{section}#{name}"] || @pages["#{section}#{name}_value"] + return page if page + end + nil + end + + # Whether the documentation being built is the one a cross-reference + # points into, rather than one linking out to it. + def documents?(prefix) + @pages.prefix.start_with?(prefix) + end + + def htmlelement(name, display = nil, anchor = nil) + name = name.downcase + display = display.presence || "<#{name}>" + link "#{DOCS}/Web/HTML/Element/#{name}#{fragment anchor}", content_for(display, nil) + end + + def httpheader(name, display = nil, anchor = nil, plain = nil) + display = display.presence || name + display = "#{display}.#{anchor}" if anchor.present? + link "#{DOCS}/Web/HTTP/Headers/#{name}#{fragment anchor}", content_for(display, plain) + end + + def svgelement(name, *) + link "#{DOCS}/Web/SVG/Reference/Element/#{name}", content_for("<#{name}>", nil) + end + + def svgattr(name, *) + link "#{DOCS}/Web/SVG/Reference/Attribute/#{name}", content_for(name, nil) + end + + def mathmlelement(name, display = nil, anchor = nil) + display = display.presence || "<#{name}>" + link "#{DOCS}/Web/MathML/Reference/Element/#{name}#{fragment anchor}", content_for(display, nil) + end + + def httpmethod(name, display = nil, anchor = nil, plain = nil) + link "#{DOCS}/Web/HTTP/Reference/Methods/#{name}#{fragment anchor}", content_for(display.presence || name, plain) + end + + def csp(directive, *) + link "#{DOCS}/Web/HTTP/Reference/Headers/Content-Security-Policy/#{directive}", content_for(directive, nil) + end + + def httpstatus(code, display = nil, anchor = nil, plain = nil) + link "#{DOCS}/Web/HTTP/Reference/Status/#{code}#{fragment anchor}", content_for(display.presence || code, plain) + end + + def webextapiref(name, display = nil, *) + slug = name.tr('.', '/') + link "#{DOCS}/Mozilla/Add-ons/WebExtensions/API/#{slug}", content_for(display.presence || name, nil) + end + + def rfc(number, title = nil, section = nil) + url = "https://datatracker.ietf.org/doc/html/rfc#{number}" + text = +"RFC #{number}" + + if section.present? + url << "#section-#{section}" + text << ", section #{section}" + end + + text << ": #{title}" if title.present? + link url, escape(text) + end + + # + # Badges and note cards + # + + def optional_inline + %(Optional) + end + + def deprecated_inline + badge 'deprecated', 'Deprecated', 'Deprecated. Not for use in new websites.' + end + + def experimental_inline + badge 'experimental', 'Experimental', 'Experimental. Expect behavior to change in the future.' + end + + def non_standard_inline + badge 'nonStandard', 'Non-standard', 'Non-standard. Check cross-browser support before using.' + end + + def readonlyinline + %(Read only) + end + + def securecontext_inline + %(Secure context) + end + + def securecontext_header + @generator.note_card 'secure', 'Secure context', <<~TEXT + This feature is available only in secure + contexts (HTTPS), in some or all supporting browsers. + TEXT + end + + # https://github.com/mdn/yari/blob/main/kumascript/macros/AvailableInWorkers.ejs + WORKERS = "Web Workers" + SERVICE_WORKERS = "Service Workers" + + WORKER_SCOPES = { + nil => "This feature is available in #{WORKERS}.", + 'worker' => "This feature is only available in #{WORKERS}.", + 'window_and_worker_except_service' => "This feature is available in #{WORKERS}, except for #{SERVICE_WORKERS}.", + 'worker_except_service' => "This feature is only available in #{WORKERS}, except for #{SERVICE_WORKERS}.", + 'window_and_worker_except_shared' => %(This feature is available in #{WORKERS}, except for Shared Web Workers.), + 'window_and_dedicated' => %(This feature is available in Dedicated Web Workers.), + 'dedicated' => %(This feature is only available in Dedicated Web Workers.), + 'window_and_service' => "This feature is available in #{SERVICE_WORKERS}.", + 'service' => "This feature is only available in #{SERVICE_WORKERS}." + } + + def availableinworkers(scope = nil) + @generator.note_card 'note', 'Note', WORKER_SCOPES.fetch(scope.presence, WORKER_SCOPES[nil]) + end + + def seecompattable + @generator.note_card(*Generator::STATUSES['experimental']) + end + + def non_standard_header + @generator.note_card(*Generator::STATUSES['non-standard']) + end + + def deprecated_header + @generator.note_card(*Generator::STATUSES['deprecated']) + end + + # + # Generated sections + # + + def compat(query = nil) + @generator.compat(queries(query, @page.browser_compat)) + end + + def specifications(query = nil) + @generator.specifications(queries(query, @page.browser_compat), @page.spec_urls) + end + + def interactiveexample(*) + %(

    Try it

    ) + end + + # The index of a landing page. MDN nests it as deep as it's asked to; + # one level is all its own pages ever ask for. + def listsubpages(path = nil, _depth = nil, reverse = nil, ordered = nil) + children = children_of(path) + return if children.empty? + + children = children.reverse if reverse.to_s == '1' + items = children.map { |child| %(
  • #{link "#{DOCS}/#{@pages.prefix}/#{child.slug}", escape(child.title)}
  • ) } + tag = ordered.to_s == '1' ? 'ol' : 'ul' + "<#{tag}>#{items.join}" + end + + def csssyntax(*) + @generator.css.syntax @page + end + + def csssyntaxraw(syntax) + @generator.css.raw_syntax syntax + end + + def cssinfo(name = nil, at_rule = nil) + @generator.css.info @page, name.presence, at_rule.presence + end + + def js_property_attributes(writable, enumerable, configurable) + rows = { 'Writable' => writable, 'Enumerable' => enumerable, 'Configurable' => configurable } + rows = rows.map { |name, value| "#{name}#{value.to_s == '1' ? 'yes' : 'no'}" } + + <<~HTML + + + #{rows.join} +
    Property attributes of #{escape @page.title}
    + HTML + end + + # MDN follows every link of this index with the opening paragraph of the + # page it points at. Reading all of those pages to summarize them is a + # lot of work for the handful of landing pages asking for it, so the + # index is the same one ListSubPages renders. + alias_method :subpageswithsummaries, :listsubpages + + # What a page carries none of into DevDocs: the sidebars, the live + # samples, the inheritance diagrams and the links to the next page. + DROPPED = %w( + addonsidebar apiref cssref css_ref defaultapisidebar htmlsidebar jsref + jssidebar mathmlref svgref webextsidebar + embedghlivesample embedlivesample embedyoutube livesamplelink + inheritancediagram previousnext previous next previousmenunext + previousmenu nextmenu listgroups apilistalpha) + + DROPPED.each { |name| define_method(name) { |*| nil } } + + MACROS = (%w( + jsxref domxref glossary cssxref htmlelement httpheader httpmethod rfc + svgelement svgattr mathmlelement csp httpstatus webextapiref + optional_inline deprecated_inline experimental_inline non_standard_inline + readonlyinline securecontext_inline + seecompattable non_standard_header deprecated_header securecontext_header + availableinworkers + compat specifications interactiveexample js_property_attributes + csssyntax csssyntaxraw cssinfo + listsubpages subpageswithsummaries) + DROPPED).to_set.freeze + + # + # Helpers + # + + def children_of(path) + @pages.children path.to_s.sub(%r{\A/[^/]+/docs/}, '').sub(%r{\A#{Regexp.escape @pages.prefix}/?}, '') + end + + def queries(query, default) + query.present? ? query.split(',').map(&:strip) : default + end + + def content_for(text, plain) + text = escape(text) + plain.present? ? text : "#{text}" + end + + def escape(text) + CGI.escape_html text.to_s + end + + def fragment(anchor) + "##{anchor.delete_prefix('#')}" if anchor.present? + end + + def link(url, content) + %(#{content}) + end + + # DevDocs styles the class of the inner element, MDN the icon around it. + def badge(name, title, description) + %(#{title}) + end + end + end +end diff --git a/lib/docs/mdn_content/markdown.rb b/lib/docs/mdn_content/markdown.rb new file mode 100644 index 0000000000..52d33ddea0 --- /dev/null +++ b/lib/docs/mdn_content/markdown.rb @@ -0,0 +1,88 @@ +require 'cgi' +require 'kramdown' +require 'kramdown-parser-gfm' + +module Docs + module MdnContent + # Renders the markdown of an MDN page. MDN renders it with remark and the + # GitHub extensions, which kramdown's GFM parser comes closest to; the + # constructs that are specific to MDN — its definition lists, note cards + # and code fences — are turned into what MDN makes of them afterwards, in + # Javascript::CleanHtmlFilter. + module Markdown + # MDN's markdown is not typeset: replacing "..." with an ellipsis or "--" + # with a dash would rewrite the code that's inline in the prose, starting + # with the names of the "try...catch" and "for...of" pages. + SYMBOLS = { + hellip: '...', + mdash: '---', + ndash: '--', + laquo: '<<', + raquo: '>>', + laquo_space: '<< ', + raquo_space: ' >>' + } + + OPTIONS = { + input: 'GFM', + auto_ids: false, + hard_wrap: false, + syntax_highlighter: nil, + smart_quotes: %w(apos apos quot quot), + typographic_symbols: SYMBOLS + } + + # The lint switch MDN appends to the name of a language, and the names it + # gives the blocks that aren't code to begin with. + NOLINT = '-nolint' + NOT_A_LANGUAGE = %w(plain none text unix) + + OPENING_FENCE = /\A([ ]{0,3})([~`]{3,})[ \t]*(\S[^\n]*)?\n?\z/ + + # A macro call and what it's held out of the parser's reach as, which is + # a word markdown has no reason to touch. + MACRO = /\{\{.*?\}\}/m + TOKEN = /kumascript(\d+)kumascript/ + + def self.render(source) + macros = [] + source = normalize_fences(source).gsub(MACRO) { |macro| "kumascript#{macros.push(macro).size - 1}kumascript" } + + # kramdown edits the options it's handed, hence the copy. + html = Kramdown::Document.new(source, OPTIONS.deep_dup).to_html + html.gsub(TOKEN) { CGI.escape_html macros[$1.to_i] } + end + + # A macro call means nothing to markdown, which is free to read the + # punctuation in its arguments as emphasis and tear it in half — as in + # {{jsxref("Statements/function*", "function*")}}. MDN puts its calls out + # of reach for the same reason; see markdown/utils/index.ts in + # https://github.com/mdn/yari. + # + # Reduces the info string of the code fences to their language. MDN tags + # them with the role they play in the page as well — "js example-bad", + # "js interactive-example" — which kramdown doesn't expect, and which + # makes it miss the fence altogether. + def self.normalize_fences(source) + closing = nil + + source.lines.map! do |line| + if closing + closing = nil if line.match?(closing) + line + elsif (fence = line.match(OPENING_FENCE)) + closing = /\A[ ]{0,3}#{fence[2]}#{fence[2][0]}*[ \t]*\n?\z/ + "#{fence[1]}#{fence[2]}#{language(fence[3])}\n" + else + line + end + end.join + end + + def self.language(info) + name = info.to_s[/\A\S+/].to_s.delete_suffix(NOLINT) + NOT_A_LANGUAGE.include?(name) ? '' : name + end + end + end +end diff --git a/lib/docs/mdn_content/pages.rb b/lib/docs/mdn_content/pages.rb new file mode 100644 index 0000000000..4deb91372e --- /dev/null +++ b/lib/docs/mdn_content/pages.rb @@ -0,0 +1,149 @@ +require 'yaml' + +module Docs + # Reads the documents of the MDN content repository + # (https://github.com/mdn/content). Its pages are markdown files with a YAML + # front matter, sprinkled with the KumaScript macro calls that MDN expands + # when it builds its site. + module MdnContent + # The characters KumaScript strips when it turns the text of a heading into + # an id. See kumascript/src/api/util.ts in https://github.com/mdn/yari. + SECTION_ID_DISALLOWED = /["#$%&+,\/:;=?@\[\]^`{|}~')(\\]/ + + # Turns the text of a heading into the id MDN gives it, which is what the + # links to its section are pointing at. + def self.slugify(text) + text.strip.gsub(SECTION_ID_DISALLOWED, '').gsub(/\s+/, '_').gsub(/\A_+|_+\z/, '').downcase + end + + Page = Struct.new(:slug, :path, :front_matter) do + def title + front_matter['title'] + end + + def short_title + front_matter['short-title'] || title + end + + def page_type + front_matter['page-type'] + end + + def status + Array(front_matter['status']) + end + + def browser_compat + Array(front_matter['browser-compat']) + end + + def spec_urls + Array(front_matter['spec-urls']) + end + + def deprecated? + status.include?('deprecated') + end + + def non_standard? + status.include?('non-standard') + end + + def experimental? + status.include?('experimental') + end + end + + # The pages of one section of the content repository, indexed by the slug + # they hang off, e.g. "Global_Objects/Array/map" for the page whose front + # matter reads "slug: Web/JavaScript/Reference/Global_Objects/Array/map". + # The section's own page is indexed under the empty slug. + class Pages + include Enumerable + + FRONT_MATTER_DELIMITER = '---' + INDEX = 'index.md' + + # The part of the slug of every page that the directory stands for. + attr_reader :prefix + + def initialize(directory, prefix) + @directory = directory + @prefix = prefix + end + + def each(&block) + index.each_value(&block) + end + + def [](slug) + index[slug.to_s.downcase] + end + + def include?(slug) + index.key?(slug.to_s.downcase) + end + + def root + index[''] + end + + # The pages one level below a slug, in alphabetical order. + def children(slug) + below = slug.to_s.downcase + below += '/' unless below.empty? + index.select { |key, _| key.start_with?(below) && key.length > below.length && !key[below.length..].include?('/') }.values + end + + # Reads the markdown of a page, without its front matter. + def body(page) + File.read(File.join(@directory, page.path)).sub(/\A#{FRONT_MATTER_DELIMITER}\n.*?\n#{FRONT_MATTER_DELIMITER}\n/m, '') + end + + private + + def index + @index ||= build + end + + def build + pages = {} + + Dir.glob(File.join(@directory, '**', INDEX)).sort.each do |file| + front_matter = read_front_matter(file) + slug = front_matter['slug'] + next unless slug + + path = file.sub(%r{\A#{Regexp.escape(@directory)}/?}, '') + slug = slug.sub(%r{\A#{Regexp.escape(@prefix)}/?}, '') + pages[slug.downcase] = Page.new(slug, path, front_matter) + end + + pages + end + + # Only the front matter is read here: the pages are parsed in parallel + # later on, and reading all of them upfront would undo that. + def read_front_matter(file) + lines = [] + first = true + + File.foreach(file) do |line| + if first + return {} unless line.start_with?(FRONT_MATTER_DELIMITER) + first = false + elsif line.start_with?(FRONT_MATTER_DELIMITER) + break + else + lines << line + end + end + + # The selectors of the CSS reference start with a colon, which YAML + # reads as a symbol; they're written back the way they came. + front_matter = YAML.safe_load(lines.join, permitted_classes: [Symbol]) || {} + front_matter.transform_values { |value| value.is_a?(Symbol) ? ":#{value}" : value } + end + end + end +end diff --git a/lib/docs/scrapers/mdn/mdn_git.rb b/lib/docs/scrapers/mdn/mdn_git.rb new file mode 100644 index 0000000000..0ee10dacff --- /dev/null +++ b/lib/docs/scrapers/mdn/mdn_git.rb @@ -0,0 +1,147 @@ +require 'docs/mdn_content/data' +require 'docs/mdn_content/generator' +require 'docs/mdn_content/macros' +require 'docs/mdn_content/markdown' +require 'docs/mdn_content/pages' + +module Docs + # The MDN documentations built from the markdown of the content repository + # (https://github.com/mdn/content) rather than from the pages MDN serves, + # which are those same documents with their sidebars, compatibility tables + # and specifications rendered into them. Everything the scrapers need is one + # download away, so a documentation is built in the time the crawler used to + # spend waiting on developer.mozilla.org. + # + # The documentations that haven't moved over yet inherit Mdn, which + # crawls developer.mozilla.org. + class MdnGit < FileScraper + self.abstract = true + self.type = 'mdn' + self.links = { + home: 'https://developer.mozilla.org', + code: 'https://github.com/mdn/content' + } + + class << self + # The directory of the content repository holding the pages, relative to + # its files/en-us, and the slug that directory stands for. + attr_accessor :content_path, :slug_prefix + + # The npm packages MDN builds its compatibility tables, its list of + # specifications and its Baseline indicators out of. A documentation can + # add the ones it needs; see Css. + attr_accessor :data_packages + + def inherited(subclass) + super + subclass.content_path = content_path + subclass.slug_prefix = slug_prefix + subclass.data_packages = data_packages.dup + end + end + + self.data_packages = { + 'browser-compat-data' => '@mdn/browser-compat-data', + 'web-features' => 'web-features', + 'web-specs' => 'web-specs' + } + + html_filters.insert_before 'normalize_urls', 'mdn_git/macros', 'mdn_git/clean_html' + + options[:trailing_slash] = false + + options[:attribution] = <<-HTML + © 2005–2025 MDN contributors.
    + Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later. + HTML + + def get_latest_version(opts) + get_latest_github_commit_date('mdn', 'content', opts) + end + + # Every page is known upfront, so there's nothing to crawl: the scraper is + # handed the whole documentation and never has to follow a link to find a + # page. + def initial_paths + @initial_paths ||= pages.map(&:slug).reject { |slug| slug.empty? || skip?(slug) }.sort + end + + def build_pages(&block) + # Reading the pages and the data files here rather than in the forked + # workers means doing it once instead of once per job. + initial_paths + options + super + end + + private + + def pages + @pages ||= begin + assert_source_directory_exists + MdnContent::Pages.new source_directory, self.class.slug_prefix + end + end + + def generator + @generator ||= MdnContent::Generator.new(MdnContent::Data.new(data_directory)) + end + + def data_directory + File.join source_directory, '_data' + end + + def skip?(slug) + slug = "/#{slug}" + Array(self.class.options[:skip]).any? { |value| slug.casecmp(value) == 0 } || + Array(self.class.options[:skip_patterns]).any? { |pattern| slug.match?(pattern) } + end + + def page_for(url) + slug = url.to_s.remove(self.class.base_url).delete_prefix('/') + pages[slug] || raise("no page for #{url}") + end + + def url_to_path(url) + page_for(url).path + end + + def parse(response) + page = page_for response.url + html = generator.header(page) + MdnContent::Markdown.render(pages.body(page)) + [Parser.new(html).html, page.title] + end + + def additional_options + { pages: pages, generator: generator } + end + + def pipeline_context(response) + super.merge page: page_for(response.url) + end + + def download_source + download_and_extract 'https://github.com/mdn/content/archive/refs/heads/main.tar.gz', + "content-main/files/en-us/#{self.class.content_path}" + + self.class.data_packages.each do |directory, package| + download_and_extract npm_tarball(package), 'package', destination: File.join(data_directory, directory) + end + + instrument 'info.doc', msg: 'Reducing the data files to what the documentation needs...' + MdnContent::Data.prepare data_directory, compat_namespaces + end + + # The sections of browser-compat-data the pages query, e.g. "javascript" + # and "api". Everything else is dropped, which is most of it. + def compat_namespaces + pages.flat_map { |page| page.browser_compat.map { |query| query.split('.').first } }.uniq + end + + def npm_tarball(package) + response = Request.run "https://registry.npmjs.org/#{package}/latest" + raise SetupError, %(Failed to look up "#{package}" on npm) unless response.success? + JSON.parse(response.body).dig('dist', 'tarball') + end + end +end From 95cf00ed4dd4b9a420dd4f560b33caafa0aa0dd7 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:46 +0200 Subject: [PATCH 4/7] Build the JavaScript documentation from the MDN content repository The 1300 pages of the reference are read off disk rather than crawled, and their compatibility tables built from a local copy of browser-compat-data rather than from one request per page to bcd.developer.mozilla.org. The entries come out unchanged. The clean_html filter goes: the markup it was undoing is MDN's, and the documents no longer carry any of it. --- lib/docs/filters/javascript/clean_html.rb | 31 -------------------- lib/docs/filters/javascript/entries.rb | 35 ++--------------------- lib/docs/scrapers/mdn/javascript.rb | 24 ++++++---------- 3 files changed, 10 insertions(+), 80 deletions(-) delete mode 100644 lib/docs/filters/javascript/clean_html.rb diff --git a/lib/docs/filters/javascript/clean_html.rb b/lib/docs/filters/javascript/clean_html.rb deleted file mode 100644 index 1b450470fc..0000000000 --- a/lib/docs/filters/javascript/clean_html.rb +++ /dev/null @@ -1,31 +0,0 @@ -module Docs - class Javascript - class CleanHtmlFilter < Filter - def call - root_page? ? root : other - doc - end - - def root - end - - def other - # Remove "style" attribute - css('.inheritsbox', '.overheadIndicator', '.blockIndicator').each do |node| - node.remove_attribute 'style' - end - - # Remove
    wrapping .overheadIndicator - css('div > .overheadIndicator:first-child:last-child', 'div > .blockIndicator:first-child:last-child').each do |node| - node.parent.replace(node) - end - - css('.baseline-indicator').each do |node| - if node.next.text == '> ' - node.next.remove - end - end - end - end - end -end diff --git a/lib/docs/filters/javascript/entries.rb b/lib/docs/filters/javascript/entries.rb index 06e4bbff67..e895b98bcf 100644 --- a/lib/docs/filters/javascript/entries.rb +++ b/lib/docs/filters/javascript/entries.rb @@ -3,14 +3,13 @@ class Javascript class EntriesFilter < Docs::EntriesFilter TYPES = %w(Array ArrayBuffer Atomics Boolean DataView Date Function Generator Intl JSON Map Math Number Object PluralRules Promise Reflect RegExp - Set SharedArrayBuffer SIMD String Symbol TypedArray WeakMap WeakSet) + Set SharedArrayBuffer String Symbol TypedArray WeakMap WeakSet) INTL_OBJECTS = %w(Collator DateTimeFormat NumberFormat) def get_name if slug.start_with? 'Global_Objects/' name, method, *rest = *slug.sub('Global_Objects/', '').split('/') name.prepend 'Intl.' if INTL_OBJECTS.include?(name) - name.prepend 'SIMD.' if html.include?("SIMD.#{name}") if method unless method == method.upcase || method == 'NaN' @@ -21,7 +20,7 @@ def get_name if name.exclude?('.prototype') path = name.split('.') - if ((node = at_css('.syntaxbox') || at_css('code')) && node.content =~ /(?:\s|\A)[a-z\_][a-zA-Z\_]+\.#{path.last}/) || + if ((node = at_css('code')) && node.content =~ /(?:\s|\A)[a-z\_][a-zA-Z\_]+\.#{path.last}/) || ((node = at_css('.standard-table')) && node.content =~ /\.prototype[\[\.]#{path.last}/) path[-2] = path[-2][0].downcase + path[-2][1..-1] name = path.join('.') @@ -59,8 +58,6 @@ def get_type 'Errors' elsif INTL_OBJECTS.include?(object) 'Intl' - elsif name.start_with?('SIMD') - 'SIMD' elsif method || TYPES.include?(object) object else @@ -70,34 +67,6 @@ def get_type 'Miscellaneous' end end - - def additional_entries - return [] unless root_page? - entries = [] - - %w(arithmetic assignment bitwise comparison logical).each do |s| - css("a[href^='operators/#{s}_operators#']").each do |node| - name = CGI::unescapeHTML(node.content.strip) - name.remove! %r{[a-zA-Z]} - name.strip! - entries << [name, node['href'], 'Operators'] - end - end - - entries.uniq - end - - def include_default_entry? - node = doc.at_css '.blockIndicator, .warning' - - # Can't use :first-child because #doc is a DocumentFragment - return true unless node && node.parent == doc && !node.previous_element - - !node.content.include?('not on a standards track') && - !node.content.include?('removed from the Web') && - !node.content.include?('SpiderMonkey-specific feature, and will be removed') && - !node.content.include?('could be removed at any time') - end end end end diff --git a/lib/docs/scrapers/mdn/javascript.rb b/lib/docs/scrapers/mdn/javascript.rb index c99c656176..1af30f50e4 100644 --- a/lib/docs/scrapers/mdn/javascript.rb +++ b/lib/docs/scrapers/mdn/javascript.rb @@ -1,30 +1,22 @@ module Docs - class Javascript < Mdn - prepend FixInternalUrlsBehavior - prepend FixRedirectionsBehavior - - # release = '2026-08-13' + class Javascript < MdnGit + # release = '2026-09-14' self.name = 'JavaScript' self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference' + self.content_path = 'web/javascript/reference' + self.slug_prefix = 'Web/JavaScript/Reference' self.links = { home: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript', code: 'https://github.com/mdn/content/tree/main/files/en-us/web/javascript' } - html_filters.push 'javascript/clean_html', 'javascript/entries' + html_filters.push 'javascript/entries' options[:root_title] = 'JavaScript' - # Duplicates - options[:skip] = %w( - /Global_Objects - /Operators - /Statements) - - options[:skip_patterns] = [ - /contributors.txt/, - /Deprecated_and_obsolete_features/ - ] + # Pages that repeat what their subpages or the reference index already say. + options[:skip] = %w(/Global_Objects /Operators /Statements) + options[:skip_patterns] = [/Deprecated_and_obsolete_features/] options[:fix_urls] = ->(url) do url.sub! '%2A', '*' From 23276f7486b12acf8f16f3bdbc86080187ac5a9b Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:46 +0200 Subject: [PATCH 5/7] Build the Web APIs documentation from the MDN content repository All 8108 pages, none of them requested over HTTP. The entries filter used to name the part of the web platform a page documents after the specification table MDN rendered into it. That table has been gone for a while, which left 2501 entries under Miscellaneous; the specifications are now looked up in browser-compat-data and web-specs, which puts 852 of them back where they belong. --- lib/docs/filters/dom/clean_html.rb | 44 ------------------------- lib/docs/filters/dom/entries.rb | 53 +++++------------------------- lib/docs/scrapers/mdn/dom.rb | 9 ++--- 3 files changed, 14 insertions(+), 92 deletions(-) delete mode 100644 lib/docs/filters/dom/clean_html.rb diff --git a/lib/docs/filters/dom/clean_html.rb b/lib/docs/filters/dom/clean_html.rb deleted file mode 100644 index d670446845..0000000000 --- a/lib/docs/filters/dom/clean_html.rb +++ /dev/null @@ -1,44 +0,0 @@ -module Docs - class Dom - class CleanHtmlFilter < Filter - def call - root_page? ? root : other - doc - end - - def root - end - - def other - css('h1 + br').remove - - # Bug fix: HTMLElement.offsetWidth - css('#offsetContainer .comment').remove - - css('section', 'font').each do |node| - node.before(node.children).remove - end - - # Bug fix: CompositionEvent, DataTransfer, etc. - if (div = at_css('div[style]')) && div['style'].include?('border: solid #ddd 2px') - div.remove - end - - # Remove double heading on SVG pages - if slug.start_with? 'SVG' - at_css('h2:first-child').try :remove - end - - # Remove
    wrapping .overheadIndicator - css('div > .overheadIndicator:first-child:last-child', 'div > .blockIndicator:first-child:last-child').each do |node| - node.parent.replace(node) - end - - css('.syntaxbox > pre:first-child:last-child').each do |node| - node['class'] = 'syntaxbox' - node.parent.before(node).remove - end - end - end - end -end diff --git a/lib/docs/filters/dom/entries.rb b/lib/docs/filters/dom/entries.rb index f65871e127..682133741f 100644 --- a/lib/docs/filters/dom/entries.rb +++ b/lib/docs/filters/dom/entries.rb @@ -220,11 +220,9 @@ def get_type return value if name =~ key end - if spec = css('.standard-table').last - spec = spec.content - TYPE_BY_SPEC.each_pair do |key, value| - return value if spec.include?(key) - end + spec = specification_titles.join(' ') + TYPE_BY_SPEC.each_pair do |key, value| + return value if spec.include?(key) end links_text = css('a').map(&:content).join @@ -239,47 +237,14 @@ def get_type end end - SKIP_CONTENT = [ - 'not on a standards track', - 'removed from the Web', - 'not on a current W3C standards track', - 'This feature is not built into all browsers', - 'not currently supported in any browser' - ] - - def include_default_entry? - return true if type == 'Console' - return true unless node = doc.at_css('.overheadIndicator, .blockIndicator') - node = node.parent while node.parent != doc - return true if node.previous_element.try(:name).in?(%w(h2 h3)) - content = node.content - SKIP_CONTENT.none? { |str| content.include?(str) } + # The specifications the page documents, which used to be read off the + # table MDN rendered into it. + def specification_titles + @specification_titles ||= context[:generator].specification_titles(page.browser_compat, page.spec_urls) end - def additional_entries - entries = [] - - if slug == 'history' || slug == 'XMLHttpRequest' - css('dt a[href^="https://developer.mozilla.org"]').each do |node| - next if node.parent.at_css('.obsolete') || node.content.include?('moz') - name = node.content.sub('History', 'history') - id = node.parent['id'] = name.parameterize - entries << [name, id] - end - end - - if slug == 'XMLHttpRequest' - css('h2[id="Methods_2"] ~ h3').each do |node| - break if node.content == 'Non-standard methods' - entries << ["#{name}.#{node.content}", node['id']] - end - end - - if slug == 'History_API' - entries << ['history.pushState()', 'The_pushState()_method'] - end - - entries + def page + context[:page] end end end diff --git a/lib/docs/scrapers/mdn/dom.rb b/lib/docs/scrapers/mdn/dom.rb index e246683a75..42d1927f26 100644 --- a/lib/docs/scrapers/mdn/dom.rb +++ b/lib/docs/scrapers/mdn/dom.rb @@ -1,17 +1,18 @@ module Docs - class Dom < Mdn - # release = '2025-09-15' + class Dom < MdnGit + # release = '2026-09-14' self.name = 'Web APIs' self.slug = 'dom' self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/API' + self.content_path = 'web/api' + self.slug_prefix = 'Web/API' self.links = { home: 'https://developer.mozilla.org/en-US/docs/Web/API', code: 'https://github.com/mdn/content/tree/main/files/en-us/web/api' } - html_filters.push 'dom/clean_html', 'dom/entries' + html_filters.push 'dom/entries' options[:root_title] = 'Web APIs' - end end From 0bf3c0a221a2e20be2b5ff0b50b945be2989da5e Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:16:47 +0200 Subject: [PATCH 6/7] Build the CSS documentation from the MDN content repository The formal syntax of a property and the table of its characteristics are generated out of mdn-data, the way MDN generates them. As with the web APIs, the entries filter was reading the part of CSS a page belongs to off a table that no longer exists, leaving 912 of 1107 entries under Miscellaneous; there are 3 left. Note that MDN has since moved its reference under Web/CSS/Reference, so the pages move with it. --- lib/docs/filters/css/clean_html.rb | 32 ----- lib/docs/filters/css/entries.rb | 182 +++++++++-------------------- lib/docs/scrapers/mdn/css.rb | 39 ++----- 3 files changed, 67 insertions(+), 186 deletions(-) delete mode 100644 lib/docs/filters/css/clean_html.rb diff --git a/lib/docs/filters/css/clean_html.rb b/lib/docs/filters/css/clean_html.rb deleted file mode 100644 index f202f7c96d..0000000000 --- a/lib/docs/filters/css/clean_html.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Docs - class Css - class CleanHtmlFilter < Filter - def call - root_page? ? root : other - doc - end - - def root - # Remove "CSS3 Tutorials" and everything after - css('#CSS3_Tutorials ~ *', '#CSS3_Tutorials').remove - end - - def other - css('.syntaxbox > .syntaxbox').each do |node| - node.parent.before(node.parent.children).remove - end - - # Remove "|" and "||" links in syntax box (e.g. animation, all, etc.) - css('.syntaxbox', '.twopartsyntaxbox').css('a').each do |node| - if node.content == '|' || node.content == '||' - node.replace node.content - end - end - - css('img[style*="float"]').each do |node| - node['style'] = node['style'] + ';float: none; display: block;' - end - end - end - end -end diff --git a/lib/docs/filters/css/entries.rb b/lib/docs/filters/css/entries.rb index 0727a6b826..cb71d1e73c 100644 --- a/lib/docs/filters/css/entries.rb +++ b/lib/docs/filters/css/entries.rb @@ -1,146 +1,78 @@ module Docs class Css class EntriesFilter < Docs::EntriesFilter - TYPE_BY_PATH = { - 'CSS_Animations' => 'Animations & Transitions', - 'CSS_Background_and_Borders' => 'Backgrounds & Borders', - 'CSS_Columns' => 'Multi-column Layout', - 'CSS_Flexible_Box_Layout' => 'Flexible Box Layout', - 'CSS_Fonts' => 'Fonts', - 'CSS_Grid_Layout' => 'Grid Layout', - 'CSS_Images' => 'Images', - 'CSS_Lists_and_Counters' => 'Lists', - 'CSS_Transforms' => 'Transforms', - 'Media_Queries' => 'Media Queries', - 'filter-function' => 'Filter Effects', - 'transform-function' => 'Transforms', - '@media' => 'Media Queries', - 'overscroll' => 'Overscroll', - 'text-size-adjust' => 'Miscellaneous', - 'resolved_value' => 'Miscellaneous', - 'touch-action' => 'Miscellaneous', - 'will-change' => 'Miscellaneous' + # What the specifications call themselves and what the documentation + # files them under. + TYPE_BY_SPEC = { + 'Compositing' => 'Compositing & Blending', + 'Custom Properties for Cascading Variables' => 'Variables', + 'Grid Layout' => 'Grid Layout', + 'Image Values & Replaced Content' => 'Image Values', + 'Scroll Snap' => 'Scroll Snap' } - DATA_TYPE_SLUGS = %w(angle basic-shape color_value counter frequency - gradient image integer length number percentage position_value ratio - resolution shape string time timing-function uri user-ident) + # The kind of page to fall back on when no specification names it. + TYPE_BY_PAGE_TYPE = { + 'css-at-rule' => 'At-rules', + 'css-at-rule-descriptor' => 'At-rules', + 'css-combinator' => 'Selectors', + 'css-function' => 'Functions', + 'css-keyword' => 'Keywords', + 'css-media-feature' => 'Media Queries', + 'css-property' => 'Properties', + 'css-pseudo-class' => 'Selectors', + 'css-pseudo-element' => 'Pseudo-Elements', + 'css-selector' => 'Selectors', + 'css-shorthand-property' => 'Properties', + 'css-type' => 'Data Types' + } + + VENDOR_PREFIXES = %w(-webkit- -moz- -ms- -o-) - FUNCTION_SLUGS = %w(attr calc cross-fade cubic-bezier cycle element - linear-gradient radial-gradient repeating-linear-gradient - repeating-radial-gradient var) + # The descriptors and the media features of an at-rule go by their own + # name in the content repository; the reference lists them under the + # at-rule they belong to. + NESTED = %w(css-at-rule-descriptor css-media-feature) def get_name - if DATA_TYPE_SLUGS.include?(slug) - "<#{super.remove ' value'}>" - elsif FUNCTION_SLUGS.include?(slug) - "#{super}()" - elsif slug =~ /\A[a-z]+_/i - slug.to_s.gsub('_', ' ').gsub('/', ': ') - elsif slug.start_with?('transform-function') || slug.start_with?('filter-function') - slug.split('/').last + '()' - else - super - end + name = page.front_matter['short-title'] || default_name + at_rule ? "#{at_rule}.#{name}" : name end - def get_type - if slug.include?('-webkit') || slug.include?('-moz') || slug.include?('-ms') - 'Extensions' - elsif type = TYPE_BY_PATH[slug.split('/').first] - type - elsif type = get_spec - type.remove! 'CSS ' - type.remove! ' Module' - type.remove! %r{ Level \d\z} - type.remove! %r{\(.*\)} - type.remove! %r{ \d\z} - type.sub! 'and', '&' - type.strip! - type = 'Grid Layout' if type.include?('Grid Layout') - type = 'Scroll Snap' if type.include?('Scroll Snap') - type = 'Compositing & Blending' if type.include?('Compositing') - type = 'Animations & Transitions' if type.in?(%w(Animations Transitions)) - type = 'Image Values' if type == 'Image Values & Replaced Content' - type = 'Variables' if type == 'Custom Properties for Cascading Variables' - type.prepend 'Miscellaneous ' if type =~ /\ALevel \d\z/ - type - elsif name.start_with?('::') - 'Pseudo-Elements' - elsif name.start_with?(':') - 'Selectors' - elsif name.start_with?('display-') - 'Display' - else - 'Miscellaneous' - end + def default_name + page.page_type.to_s.start_with?('css-') ? slug.split('/').last : page.title end - STATUSES = { - 'spec-Living' => 0, - 'spec-REC' => 1, - 'spec-CR' => 2, - 'spec-PR' => 3, - 'spec-LC' => 4, - 'spec-WD' => 5, - 'spec-ED' => 6, - 'spec-Obsolete' => 7 - } - - PRIORITY_STATUSES = %w(spec-REC spec-CR) - PRIORITY_SPECS = ['CSS Basic Box Model', 'CSS Lists and Counters', 'CSS Paged Media'] - - def get_spec - return unless table = at_css('#Specifications + table') || css('.standard-table').last - - specs = table.css('tbody tr').to_a - # [link, span] - specs.map! { |node| [node.at_css('> td:nth-child(1) > a'), node.at_css('> td:nth-child(2) > span')] } - # ignore non-CSS specs - specs.select! { |pair| pair.first && pair.first['href'] =~ /css|fxtf|fullscreen|svg/i && !pair.first['href'].include?('compat.spec') } - # ignore specs with no status - specs.select! { |pair| pair.second } - # ["Spec", "spec-REC"] - specs.map! { |pair| [pair.first.child.content, pair.second['class']] } - # sort by status - specs.sort_by! { |pair| [STATUSES[pair.second], pair.first] } - - spec = specs.find { |pair| PRIORITY_SPECS.any? { |s| pair.first.start_with?(s) } && name != 'display' } - spec ||= specs.find { |pair| !pair.first.start_with?('CSS Level') && pair.second.in?(PRIORITY_STATUSES) } - spec ||= specs.find { |pair| pair.second == 'spec-WD' } if specs.count { |pair| pair.second == 'spec-WD' } == 1 - spec ||= specs.first + def at_rule + return unless NESTED.include?(page.page_type) + context[:pages][slug.split('/')[0..-2].join('/')]&.short_title + end - spec.try(:first) + def get_type + return 'Extensions' if VENDOR_PREFIXES.any? { |prefix| name.include?(prefix) } + specification || TYPE_BY_PAGE_TYPE[page.page_type] || 'Miscellaneous' end - ADDITIONAL_ENTRIES = { - 'shape' => [ - %w(rect() Syntax) ], - 'timing-function' => [ - %w(cubic-bezier()), - %w(steps()), - %w(linear linear), - %w(ease ease), - %w(ease-in ease-in), - %w(ease-in-out ease-in-out), - %w(ease-out ease-out), - %w(step-start step-start), - %w(step-end step-end) ], - 'color_value' => [ - %w(transparent transparent), - %w(currentColor currentColor), - %w(rgb() rgba()), - %w(hsl() hsla()), - %w(rgba() rgba()), - %w(hsla() hsla()) ]} + # The specification a page documents, cut down to the part of CSS it + # covers: "CSS Grid Layout Module Level 2" is the Grid Layout pages. + def specification + title = context[:generator].specification_titles(page.browser_compat, page.spec_urls).first + return if title.blank? + + title = title.dup + title.remove! 'CSS ' + title.remove! ' Module' + title.remove! %r{ Level \d+\z} + title.remove! %r{\(.*\)} + title.sub! ' and ', ' & ' + title.squish! - def additional_entries - ADDITIONAL_ENTRIES[slug] || [] + _, type = TYPE_BY_SPEC.find { |spec, _| title.include?(spec) } + type || (title.match?(/\ALevel \d+\z/) ? nil : title.presence) end - def include_default_entry? - return true unless warning = at_css('.warning').try(:content) - !warning.include?('CSS Flexible Box') && !warning.include?('replaced in newer drafts') + def page + context[:page] end end end diff --git a/lib/docs/scrapers/mdn/css.rb b/lib/docs/scrapers/mdn/css.rb index bcf34dceb4..33cbd2f21e 100644 --- a/lib/docs/scrapers/mdn/css.rb +++ b/lib/docs/scrapers/mdn/css.rb @@ -1,40 +1,21 @@ module Docs - class Css < Mdn - # release = '2025-09-15' + class Css < MdnGit + # release = '2026-09-14' self.name = 'CSS' - self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/CSS' - self.root_path = '/Reference' + self.base_url = 'https://developer.mozilla.org/en-US/docs/Web/CSS/Reference' + self.content_path = 'web/css/reference' + self.slug_prefix = 'Web/CSS/Reference' self.links = { home: 'https://developer.mozilla.org/en-US/docs/Web/CSS', code: 'https://github.com/mdn/content/tree/main/files/en-us/web/css' } - html_filters.push 'css/clean_html', 'css/entries' + # The formal syntax and the table of characteristics of a property are + # MDN's, not its authors'. + self.data_packages = data_packages.merge('mdn-data' => 'mdn-data') - options[:root_title] = 'CSS' - - options[:skip] = %w(/CSS3 /Media/Visual /paged_media /Media/TV /Media/Tactile) - options[:skip] += %w(/mq-boolean /single-transition-timing-function) # bug - options[:skip_patterns] = [/Extensions/, /Tools/, /@media\/-webkit/, /webkit-mask/, /-moz-system-metric/] + html_filters.push 'css/entries' - options[:replace_paths] = { - '/%3Cbasic-shape%3E' => '/basic-shape', - '/fallback' => '/@counter-style/fallback', - '/range' => '/@counter-style/range', - '/symbols' => '/@counter-style/symbols', - '/system' => '/@counter-style/system', - '/var' => '/var()', - '/element' => '/element()', - '/Flexbox' => '/CSS_Flexible_Box_Layout/Using_CSS_flexible_boxes', - '/flexbox' => '/CSS_Flexible_Box_Layout/Using_CSS_flexible_boxes', - '/currentColor' => '/color_value' - } - - options[:fix_urls] = ->(url) do - url.sub! %r{https://developer\.mozilla\.org/en\-US/docs/CSS/([\w\-@:])}, "#{Css.base_url}/\\1" - url.sub! '%3A', ':' - url.sub! '%40', '@' - url - end + options[:root_title] = 'CSS' end end From 25c3f9b4b502076aa221125a4514e5072113560e Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 14:20:52 +0200 Subject: [PATCH 7/7] Expand the macro calls MDN leaves inside its prose and its data Four things kept a call from being expanded or a link from resolving: - A call inside an inline code span was skipped along with the ones in the code blocks, which are the only ones meant to read literally. - The tables generated out of mdn-data are written with the same cross-references as the prose, so an expansion can hold a call itself. - An argument is written the way it reads, quoted with backticks as often as not, escaped as <color> rather than , and left out altogether in {{rfc("7002",,"3.2")}}. - Neither the fragments of browser-compat-data nor the ones MDN's authors write are escaped, and URL.parse rejects both. The JavaScript and CSS documentations come out with no call left unexpanded and no dead link; the two remaining in the web APIs are a code fence upstream forgot to close. --- lib/docs/mdn_content/macros.rb | 14 +++++++++++--- lib/docs/scrapers/mdn/mdn_git.rb | 7 +++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/docs/mdn_content/macros.rb b/lib/docs/mdn_content/macros.rb index de7d2f416d..ad953aa852 100644 --- a/lib/docs/mdn_content/macros.rb +++ b/lib/docs/mdn_content/macros.rb @@ -75,7 +75,9 @@ def parse(scanner) scanner.scan(/\s*`([^`]*)`/) scanner[1] else - scanner.scan(/[^,)]+/) + # An argument can be left out, as in {{rfc("7002",,"3.2")}}, + # in which case there's nothing to scan but a comma follows. + scanner.scan(/[^,)]+/) || ('' if scanner.match?(/,/)) end # Anything else isn't a call, and would have the scanner stand @@ -141,12 +143,12 @@ def glossary(term, display = nil, plain = nil) def cssxref(name, display = nil, anchor = nil) page = css_page(name) if documents?(CSS) display = display.presence || page&.short_title || name - slug = page ? page.slug : name.remove('()') + slug = page ? page.slug : css_name(name) link "#{DOCS}/#{CSS}#{'/Reference' if page}/#{slug}#{fragment anchor}", content_for(display, nil) end def css_page(name) - name = name.remove('()').remove(/\A<|>\z/) + name = css_name(name) CSS_SECTIONS.each do |section| page = @pages["#{section}#{name}"] || @pages["#{section}#{name}_value"] return page if page @@ -154,6 +156,12 @@ def css_page(name) nil end + # A type is referred to as and a function as calc(), neither of + # which is part of the name of their page. + def css_name(name) + name.remove('()').remove(/\A<|>\z/) + end + # Whether the documentation being built is the one a cross-reference # points into, rather than one linking out to it. def documents?(prefix) diff --git a/lib/docs/scrapers/mdn/mdn_git.rb b/lib/docs/scrapers/mdn/mdn_git.rb index 0ee10dacff..9fe6e0a24c 100644 --- a/lib/docs/scrapers/mdn/mdn_git.rb +++ b/lib/docs/scrapers/mdn/mdn_git.rb @@ -50,6 +50,13 @@ def inherited(subclass) options[:trailing_slash] = false + # The links MDN's authors write aren't escaped, and the id of a heading is + # its text: a section called "Guideline 1.1 — providing text alternatives" + # is linked to by a fragment holding that em dash. + options[:fix_urls_before_parse] = ->(url) do + url.gsub(/[^\x21-\x7E]/) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join } + end + options[:attribution] = <<-HTML © 2005–2025 MDN contributors.
    Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.