diff --git a/_layouts/json.json b/_layouts/json.json new file mode 100644 index 000000000000..8c98299c885e --- /dev/null +++ b/_layouts/json.json @@ -0,0 +1 @@ +{{ page.data | jsonify }} diff --git a/_plugins/generate-api-v1.rb b/_plugins/generate-api-v1.rb index 2f93923af10f..2b5f46c0b342 100755 --- a/_plugins/generate-api-v1.rb +++ b/_plugins/generate-api-v1.rb @@ -1,89 +1,228 @@ -#!/usr/bin/env ruby - # This script creates API files for version 1 of the endoflife.date API. # -# There are three kind of generated files : -# - all.json: contains the list of all the product names. -# - .json: contains a given product data ()including releases data). -# - /.json: contains a given product release data. +# There are two kind of generated files : +# - all.json: contains endoflife metadata, such as the list of all the products and the product count. +# - .json: contains a given product data (including releases data). -require 'fileutils' -require 'json' -require 'yaml' -require 'date' +require 'jekyll' module ApiV1 - # This API path - DIR = 'api/v1' + STRIP_HTML_BLOCKS = Regexp.union( + //m, + //m, + //m + ) + STRIP_HTML_TAGS = /<.*?>/m - # Returns the path of a file inside the API namespace. - def self.file(name, *args) - File.join(DIR, name, *args) + # Remove HTML from a string (such as an LTS label). + # This is the equivalent of Liquid::StandardFilters.strip_html, which cannot be used + # unfortunately. + def self.strip_html(input) + empty = ''.freeze + result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty) + result.gsub!(STRIP_HTML_TAGS, empty) + result end - # Holds information about a product. - Product = Class.new do - attr_accessor :data - - # Initializes the product with the given product's markdown file. - # The markdown file is expected to contain a YAML front-matter with the appropriate properties. - # - # Copying the data makes it easier to process it. - def initialize(data) - @data = Hash.new - # The product name is derived from the product's permalink (ex. /debian => debian). - @data["name"] = data['permalink'][1..data['permalink'].length] - @data["title"] = data['title'] - @data["category"] = data['category'] - @data["iconSlug"] = data['iconSlug'] - @data["permalink"] = data['permalink'] - @data["versionCommand"] = data['versionCommand'] - @data["auto"] = data.has_key? 'auto' - @data["releasePolicyLink"] = data['releasePolicyLink'] - @data["releases"] = data['releases'].map do |release| - release_data = Hash.new - release_data["name"] = release['releaseCycle'] - release_data["codename"] = release['codename'] - release_data["releaseDate"] = release['releaseDate'] - release_data["support"] = release['support'] - release_data["eol"] = release['eol'] - release_data["discontinued"] = release['discontinued'] - release_data["lts"] = release['lts'] || false # lts is optional, make sure it always has a value - release_data["latest"] = release['latest'] - release_data["latestReleaseDate"] = release['latestReleaseDate'] - release_data + class ApiGenerator < Jekyll::Generator + safe true + priority :lowest + + TOPIC = "EndOfLife API v1:" + + def generate(site) + @site = site + Jekyll.logger.info TOPIC, "Generating API for products" + + add_index_page(site) + product_pages = add_product_pages(site) + add_all_products_page(site, product_pages) + + add_category_pages(site, product_pages) + add_all_categories_page(site, product_pages) + + add_tag_pages(site, product_pages) + add_all_tags_page(site, product_pages) + end + + private + + def add_index_page(site) + site_url = site.config['url'] + site.pages << JsonPage.new(site, '/', 'index', { + result: [ + { name: "products", uri: "#{site_url}/api/v1/products/" }, + { name: "categories", uri: "#{site_url}/api/v1/categories/" }, + { name: "tags", uri: "#{site_url}/api/v1/tags/" }, + ] + }) + end + + def add_product_pages(site) + product_pages = [] + + site.pages.each do |page| + if page.data['layout'] == 'product' + product_pages << page + site.pages << ProductJsonPage.new(site, page) + + site.pages << ProductCycleJsonPage.new(site, page, page.data['releases'][0], 'latest') + page.data['releases'].each do |cycle| + site.pages << ProductCycleJsonPage.new(site, page, cycle) + end + end + end + + return product_pages + end + + def add_all_products_page(site, products) + site.pages << ProductsJsonPage.new(site, '/products/', 'index', products) + end + + def add_category_pages(site, products) + pages_by_category = {} + + products.each do |product| + category = product.data['category'] || 'unknown' + add_to_map(pages_by_category, category, product) + end + + pages_by_category.each do |category, products| + site.pages << ProductsJsonPage.new(site, "/categories/#{category}", 'index', products) end end - def name - data["name"] + def add_all_categories_page(site, products) + site.pages << JsonPage.new(site, '/categories/', 'index', { + result: products + .map { |product| product.data['category'] } + .uniq + .sort + .map { |category| { name: category, uri: "#{site.config['url']}/api/v1/categories/#{category}/" } } + }) + end + + def add_tag_pages(site, products) + products_by_tag = {} + + products.each do |product| + product.data['tags'].each { |tag| add_to_map(products_by_tag, tag, product) } + end + + products_by_tag.each do |tag, products| + site.pages << ProductsJsonPage.new(site, "/tags/#{tag}", 'index', products) + end + end + + def add_all_tags_page(site, products) + site.pages << JsonPage.new(site, '/tags/', 'index', { + result: products + .flat_map { |product| product.data['tags'] } + .uniq + .sort + .map { |tag| { name: tag, uri: "#{site.config['url']}/api/v1/tags/#{tag}/" } } + }) + end + + def add_to_map(map, key, page) + if map.has_key? key + map[key] << page + else + map[key] = [page] + end end end -end -product_names = [] -FileUtils.mkdir_p(ApiV1::file('.')) - -Dir['products/*.md'].each do |file| - # Load and prepare data - raw_data = YAML.safe_load(File.open(file), permitted_classes: [Date]) - product = ApiV1::Product.new(raw_data) - product_names.append(product.name) - - # Write /.json - product_file = ApiV1::file("#{product.name}.json") - File.open(product_file, 'w') { |f| f.puts product.data.to_json } - - # Write all //.json - FileUtils.mkdir_p(ApiV1::file(product.name)) - product.data["releases"].each do |release| - # Any / characters in the name are replaced with - to avoid file errors. - release_file = ApiV1::file(product.name, "#{release['name'].to_s.tr('/', '-')}.json") - File.open(release_file, 'w') { |f| f.puts release.to_json } + class JsonPage < Jekyll::Page + def initialize(site, path, name, data) + @site = site + @base = site.source + @dir = "api/v1#{path}" + @name = "#{name}.json" + @data = {} + @data['layout'] = 'json' + @data['data'] = data + + self.process(@name) + end + end + + class ProductsJsonPage < JsonPage + def initialize(site, path, name, products) + super(site, path, name, { + total: products.size(), + products: products.map { |product| { + name: product.data['id'], + title: product.data['title'], + category: product.data['category'], + tags: product.data['tags'], + uri: "#{site.config['url']}/api/v1/products/#{product.data['id']}/", + } } + }) + end end -end -# Write /all.json -all_products_file = ApiV1::file('all.json') -File.open(all_products_file, 'w') { |f| f.puts product_names.sort.to_json } + class ProductJsonPage < JsonPage + def initialize(site, product) + id = product.data['id'] + super(site, "/products/#{id}", 'index', { + name: id, + label: product.data['title'], + category: product.data['category'], + tags: product.data['tags'], + links: { + icon: product.data['iconUrl'], + html: "#{site.config['url']}/#{id}", + releasePolicyLink: product.data['releasePolicyLink'], + }, + versionCommand: product.data['versionCommand'], + lastModified: product.data['lastModified'], + auto: product.data.has_key?('auto'), + cycles: [ + product.data['releases'] + .map { |cycle| { + name: cycle['releaseCycle'], + codename: cycle['codename'], + label: ApiV1.strip_html(cycle['label']), + date: cycle['releaseDate'], + support: cycle['support'], + lts: cycle['lts'], + eol: cycle['eol'], + discontinued: cycle['discontinued'], + extendedSupport: cycle['extendedSupport'], + latest: { + version: cycle['latest'], + date: cycle['latestReleaseDate'], + link: cycle['link'], + } + } } + ] + }) + end + end + + class ProductCycleJsonPage < JsonPage + def initialize(site, product, cycle, identifier = nil) + name = identifier ? identifier : cycle['id'] + + super(site, "/products/#{product.data['id']}/cycles/#{name}", 'index', { + name: cycle['releaseCycle'], + codename: cycle['codename'], + label: ApiV1.strip_html(cycle['label']), + date: cycle['releaseDate'], + support: cycle['support'], + lts: cycle['lts'], + eol: cycle['eol'], + discontinued: cycle['discontinued'], + extendedSupport: cycle['extendedSupport'], + latest: { + version: cycle['latest'], + date: cycle['latestReleaseDate'], + link: cycle['link'], + } + }) + end + end +end diff --git a/_plugins/product-data-enricher.rb b/_plugins/product-data-enricher.rb new file mode 100644 index 000000000000..8ca9c3013d57 --- /dev/null +++ b/_plugins/product-data-enricher.rb @@ -0,0 +1,123 @@ +# This plugin enriches the product pages setting or precomputing field values, so that it can be +# easily consumed in layouts or plugins (such as the APIv1 plugin). +module Jekyll + class ProductDataEnricher + class << self + + TOPIC = "EndOfLife Product Data Enricher:" + + def enrich(page) + Jekyll.logger.debug TOPIC, "Enriching #{page.name}" + + set_id(page) + set_icon_url(page) + set_tags(page) + + page.data["releases"].each { |release| enrich_release(page, release) } + end + + def is_product?(page) + page.data['layout'] == 'product' + end + + private + + # Build the product id from the permalink. + def set_id(page) + page.data['id'] = page.data['permalink'][1..page.data['permalink'].length] + end + + # Build the icon URL from the icon slug. + def set_icon_url(page) + if page['iconSlug'] + page.data['iconUrl'] = "https://simpleicons.org/icons/#{page['iconSlug']}.svg" + end + end + + # Explode tags space-separated string to a list if necessary. + # Also add the category as a default tag. + def set_tags(page) + tags = page.data['tags'] + + if tags + tags = (tags.kind_of?(Array) ? tags : tags.split) + else + tags = [] + end + + tags << page.data['category'] + page.data['tags'] = tags + end + + def enrich_release(page, cycle) + set_cycle_id(cycle) + set_cycle_lts(cycle) + set_cycle_discontinued(cycle) + set_cycle_link(page, cycle) + set_cycle_label(page, cycle) + set_cycle_lts_label(page, cycle) + end + + # Build the cycle id from the permalink. + def set_cycle_id(cycle) + cycle['id'] = cycle['releaseCycle'].tr('/', '-') + end + + def set_cycle_lts(cycle) + if !cycle['lts'] + cycle['lts'] = false + end + end + + def set_cycle_discontinued(cycle) + if !cycle['discontinued'] + cycle['discontinued'] = false + end + end + + def set_cycle_link(page, cycle) + if !cycle['link'] && page['changelogTemplate'] + link = page['changelogTemplate'].gsub('__RELEASE_CYCLE__', cycle['releaseCycle'] || '') + link.gsub!('__CODENAME__', cycle['codename'] || '') + link.gsub!('__LATEST__', cycle['latest'] || '') + link.gsub!('__LATEST_RELEASE_DATE__', cycle['latestReleaseDate'] ? cycle['latestReleaseDate'].iso8601 : '') + cycle['link'] = Liquid::Template.parse(link).render(@context) + end + end + + def set_cycle_label(page, cycle) + template = cycle['releaseLabel'] || page.data['releaseLabel'] + + if template + label = template.gsub('__RELEASE_CYCLE__', cycle['releaseCycle'] || '') + label.gsub!('__CODENAME__', cycle['codename'] || '') + label.gsub!('__LATEST__', cycle['latest'] || '') + cycle['label'] = Liquid::Template.parse(label).render(@context) + else + cycle['label'] = cycle['releaseCycle'] + end + end + + def set_cycle_lts_label(page, cycle) + if cycle['lts'] + lts = cycle['lts'] + lts_label = page.data.has_key?('LTSLabel') ? page.data['LTSLabel'] : 'LTS' + + if lts == true + cycle['label'] = "#{cycle['label']} (#{lts_label})" + elsif lts.respond_to?(:strftime) # lts is a date + if lts > Date.today + cycle['label'] = "#{cycle['label']} (Upcoming #{lts_label})" + else + cycle['label'] = "#{cycle['label']} (#{lts_label})" + end + end + end + end + end + end +end + +Jekyll::Hooks.register [:pages], :post_init do |page| + Jekyll::ProductDataEnricher.enrich(page) if Jekyll::ProductDataEnricher.is_product?(page) +end diff --git a/_redirects b/_redirects index 8b853c2d86a9..04d8ef5a2637 100644 --- a/_redirects +++ b/_redirects @@ -9,7 +9,13 @@ # Setting a layout forces Jekyll to render this file layout: null --- -{%- for page in site.pages -%} +# Rewrite for /api/v1/ +# All API responses are located in an index.json and must be accessible without the file name, such as : +# - /api/v1/index.json -> /api/v1/ +# - /api/v1/products/almalinux/index.json -> /api/v1/products/almalinux/ +/api/v1/* /api/v1/:splat/index.json 200! + +{% for page in site.pages -%} # Redirects for {{page.path}} {%- if page.alternate_urls %} {%- for url in page.alternate_urls %} diff --git a/products/raspberrypi.md b/products/raspberrypi.md index 71e5c8ac68c5..c42a20516c93 100644 --- a/products/raspberrypi.md +++ b/products/raspberrypi.md @@ -37,7 +37,7 @@ releases: link: https://www.raspberrypi.com/products/raspberry-pi-pico/ - releaseCycle: "4-400" - releaseLabel: 400 + releaseLabel: "400" # https://www.raspberrypi.com/news/raspberry-pi-400-the-70-desktop-pc/ releaseDate: 2020-11-02 discontinued: 2026-01-01