diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b37f6..51ec041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: - name: Run tests run: bundle exec rake spec + env: + SPEC_MD: spec/kramdown_rpf-legacy-spec.md publish: needs: [lint, test] diff --git a/Gemfile b/Gemfile index 5417310..ee764c8 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,10 @@ source 'https://rubygems.org' gemspec group :development do + gem 'compare-xml' + gem 'nokogiri' gem 'rake' + gem 'rexml', '~> 3.4' gem 'rspec', require: false gem 'rubocop', require: false gem 'rubocop-performance', require: false diff --git a/README.md b/README.md index 0525736..8706033 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,24 @@ question: Here is a heading for a quiz with three possible answers. How do you f After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +### Testing against the spec + +The formal spec lives at `spec/kramdown_rpf-legacy-spec.md`. This is used to test that the output of the gem matches the expected output for a variety of inputs. To run these tests, run: + +```sh +bundle exec rspec +``` + +If you wish to use a different spec, you can set the `SPEC_MD` environment variable: + +``` +SPEC_MD=my-new-spec.md bundle exec rake spec +``` + +This is also run automatically in CI. + +**If you add or change examples in `examples/`, you must update the spec file accordingly** — both here in `spec/kramdown_rpf-legacy-spec.md` and in the canonical copy in the [documentation repository](https://github.com/RaspberryPiFoundation/documentation) at `docs/technology/codebases-and-products/raspberry-flavoured-markdown/kramdown_rpf-legacy-spec.md`. + To install this gem onto your local machine, run `bundle exec rake install`. ### Release a new version diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index 7de937a..c12e0e3 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -20,8 +20,10 @@ values = file_contents[1]['kramdown_rpf'] context("with #{locale} locale") do - before do + around do |example| I18n.locale = locale + example.run + I18n.locale = I18n.default_locale end it('converts hint title') do diff --git a/spec/kramdown_rpf-legacy-spec.md b/spec/kramdown_rpf-legacy-spec.md new file mode 100644 index 0000000..f38659a --- /dev/null +++ b/spec/kramdown_rpf-legacy-spec.md @@ -0,0 +1,1326 @@ +--- +title: Kramdown RPF -- Legacy spec +--- + +# Spec — kramdown-rpf version 0.12.0 + +:::info +This spec was built from the example files in the `kramdown-rpf` repository. It +is intended to be a single source of truth for the expected behaviour of the +custom block syntax, and to be used as the basis for test suites in both the +Ruby and TypeScript renderers. Any changes to the syntax or expected output +should be made here first, and then the tests in both repositories should be +updated to match. +::: + +This document is the formal specification for the **legacy** custom block syntax used in +Raspberry Pi Foundation project content. It is parsed by `kramdown-rpf` (Ruby) +and the `rpf-markdown-core` (TypeScript) renderers. + +Each **example** below shows the markdown input above the `·` separator and the +expected HTML output below it. These examples are the canonical test suite — +the parsers in both renderers are expected to produce output that matches +exactly (modulo leading/trailing whitespace). + +This spec replaces the original `example/` directory in the `kramdown-rpf` +repository. It is intended to be a single source of truth for the expected +behaviour of the custom block syntax, and to be used as the basis for test +suites in both the Ruby and TypeScript renderers. Any changes to the syntax or +expected output should be made here first, and then the tests in both +repositories should be updated to match. + +--- + +## How to read this spec + +```text +This is the markdown input. +· +
This is the expected HTML output.
+``` + +The separator is a middle dot (`·`) on its own line. Test runners split on +`\n·\n`. + +--- + +## Hint + +A single `hint` block renders as a swiper slide. It is always used inside a `hints` block in +practice but can be used standalone. + +```example +--- hint --- + +Some hint content + +--- /hint --- +· + +``` + +--- + +## Hints + +A `hints` block wraps one or more `hint` blocks in a swiper panel with pagination controls. + +```example +--- hints --- +--- hint --- + +Hint 1 + +--- /hint --- +--- hint --- + +Hint 2 + +--- /hint --- + +--- /hints --- +· +Add global direction to your function:
+ +def joystick_moved(event):
+ global direction
+
+
+ You can access the direction the joystick was moved in with the help of the event parameter: use the command event.direction.
Add global direction to your function:
+def joystick_moved(event):
+ global direction
+
+ You can access the direction the joystick was moved in with the help of the event parameter: use the command event.direction.
Add global direction to your function:
+def joystick_moved(event):
+ global direction
+
+ You can access the direction the joystick was moved in with the help of the event parameter: use the command event.direction.
Content here comes from the ingredient.
+Can you improve your project?
+``` + +--- + +## Code block + +The `code` block provides a styled code display with optional filename, line numbers, and line +highlights. It uses a YAML front matter section to configure the display. + +### Language only + +```example +--- code --- +--- +language: python +--- +print("Hello, World!") +--- /code --- +· +
+print("Hello, World!")
+
+```
+
+### With filename
+
+```example
+--- code ---
+---
+language: python
+filename: hello.py
+---
+print("Hello, World!")
+--- /code ---
+·
+
+print("Hello, World!")
+
+```
+
+### With line numbers
+
+```example
+--- code ---
+---
+language: python
+line_numbers: true
+---
+print("Hello, World!")
+--- /code ---
+·
+
+print("Hello, World!")
+
+```
+
+### With line highlights
+
+```example
+--- code ---
+---
+language: python
+line_highlights: 1
+---
+print("Hello, World!")
+--- /code ---
+·
+
+print("Hello, World!")
+
+```
+
+### With all features
+
+```example
+--- code ---
+---
+filename: button_press.py
+language: python
+line_numbers: true
+line_number_start: 3
+line_highlights: 3, 5-6
+---
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+--- /code ---
+·
+
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+
+```
+
+### Fenced
+
+A plain fenced code block.
+
+````example
+```python
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+```
+·
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+
+````
+
+### With multi-line content
+
+```example
+--- code ---
+---
+language: python
+---
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+--- /code ---
+·
+
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+
+```
+
+### With line numbers disabled
+
+```example
+--- code ---
+---
+language: python
+line_numbers: false
+---
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+--- /code ---
+·
+
+while True:
+ button.wait_for_press()
+ parp = random.choice(trumps)
+ os.system("aplay {0}".format(parp))
+ sleep(2)
+
+```
+
+### With angle brackets in content
+
+```example
+--- code ---
+---
+language: cs
+filename: StarController.cs - OnTriggerEnter(Collider other)
+line_numbers: true
+line_number_start: 21
+line_highlights: 26, 27
+---
+ void OnTriggerEnter(Collider other)
+ {
+ // Check the tag of the colliding object
+ if (other.CompareTag("Player"))
+ {
+ StarPlayer player = other.gameObject.GetComponent
+ void OnTriggerEnter(Collider other)
+ {
+ // Check the tag of the colliding object
+ if (other.CompareTag("Player"))
+ {
+ StarPlayer player = other.gameObject.GetComponent<StarPlayer>();
+ player.stars += 1; // Increase by 1
+ AudioSource.PlayClipAtPoint(collectSound, transform.position);
+ gameObject.SetActive(false);
+ }
+ }
+
+```
+
+---
+
+## Collapse
+
+A `collapse` block renders as a collapsible ingredient panel. It requires a YAML front matter section
+with a `title` field. The body is parsed as markdown.
+
+```example
+--- collapse ---
+---
+title: How to do something
+---
+
+Here is some useful information.
+
+--- /collapse ---
+·
+Here is some useful information.
+
+vowels = 'AEIOU' # The variable holds a string of vowels
+vowel_list = list(vowels) # Create a list that holds each vowel as a separate item
+print(vowel_list) # Display the list of vowels
+
+
+The output of this code would be:
+ +['A', 'E', 'I', 'O', 'U']
+
+
+
+--- /collapse ---
+
+1. Now that you know how to get Steve's position, you can begin your program by storing his poition as three variables. You can use `px`, `py`, and `pz`
+
+~~~ python
+px, py, pz = mc.player.getPos()
+~~~
+·
+main.py
+
+vowels = 'AEIOU' # The variable holds a string of vowels
+vowel_list = list(vowels) # Create a list that holds each vowel as a separate item
+print(vowel_list) # Display the list of vowels
+
+ The output of this code would be:
+['A', 'E', 'I', 'O', 'U']
+
+ px, py, and pzpx, py, pz = mc.player.getPos()
+
+```
+
+### Inside a list item
+
+````example
+## Step 2 - Test the PIR motion sensor
+
+We're going to write some code to print out `Motion detected!` when the PIR sensor detects movement.
+
+1. Open IDLE, create a new file and save it as **parent-detector.py**
+
+ --- collapse ---
+ ---
+ title: Opening IDLE
+ image: images/idle.png
+ ---
+
+ [[[idle-opening]]]
+
+ --- /collapse ---
+
+1. Blab la
+ ```python
+ from gpiozero import MotionSensor
+
+ pir = MotionSensor(4)
+ ```
+
+2. Bla bla
+
+ ```python
+ while True:
+ if pir.motion_detected:
+ print("Motion detected!")
+ ```
+·
+We’re going to write some code to print out Motion detected! when the PIR sensor detects movement.
Open IDLE, create a new file and save it as parent-detector.py
+[[[idle-opening]]]
+ from gpiozero import MotionSensor
+
+ pir = MotionSensor(4)
+
+ Bla bla
+ while True:
+ if pir.motion_detected:
+ print("Motion detected!")
+
+ There are two ways to create directories on the Raspberry Pi. The first uses the GUI, and the second uses the Terminal.
+ +
Open a File Manager window by clicking on the icon in the top left corner of the screen
+ +

Open a new Terminal window by clicking on the icon in the top left corner of the screen.
+ +
You can create a new directory using the mkdir command
mkdir my-new-directory +
lsTo enter your new directory use the cd command
cd my-new-directory +
There are two ways to create directories on the Raspberry Pi. The first uses the GUI, and the second uses the Terminal.
+
Open a File Manager window by clicking on the icon in the top left corner of the screen
+

Open a new Terminal window by clicking on the icon in the top left corner of the screen.
+
You can create a new directory using the mkdir command
mkdir my-new-directory ++
lsTo enter your new directory use the cd command
cd my-new-directory ++
Seeing the intruder on the screen in a camera preview isn’t much help to you with detecting intruders into your room. Instead, let’s record a video of the intruder for you to view later on when you get home.
+Create a variable called filename inside your infinite loop to store the video file name
filename = "intruder.h264"
+
+ In case you are wondering, .h264 is the video format
Find the line of code where you begin the camera preview and replace it with a line of code to start recording a video
+ camera.start_recording(filename)
+
+ Find the line of code where you stop the camera preview and replace it with a line of code to stop recording.
+intruder.h264 appears in the same folder as your parent-detector.py file.Every time a new intruder triggers the motion sensor the video will be overwritten. If you have lots of pesky parents or brothers and sisters intruding into your room, you want to keep videos of all of them. Can you write some code to automatically find out the current date and time and add it to the video filename so that each video we take will have a different filename?
+[[[generic-python-timestamps]]]
+First Page
+ +Second Page
+``` + +--- + +## No print + +Content inside a `no-print` block is hidden when printing. + +```example +--- no-print --- +This won't print. +--- /no-print --- +· +This won’t print.
+This only appears in print.
+let x = 5
+
+````
+
+---
+
+## Scratch code blocks
+
+Fenced code blocks with language `blocks3` (Scratch 3) or `blocks` (Scratch 2) render with the
+corresponding class for the scratchblocks rendering library.
+
+````example
+```blocks3
+when flag clicked
+```
+·
+when flag clicked
+
+````
+
+````example
+```blocks
+when flag clicked
+```
+·
+when flag clicked
+
+````
diff --git a/spec/kramdown_rpf_spec.rb b/spec/kramdown_rpf_spec.rb
index 97e5e24..c32e87d 100644
--- a/spec/kramdown_rpf_spec.rb
+++ b/spec/kramdown_rpf_spec.rb
@@ -10,9 +10,9 @@
code/code_with_all_features
code/code_with_angle_brackets
code/code_with_filename
+ code/code_with_line_highlights
code/code_with_line_numbers
code/code_with_no_line_numbers
- code/code_with_line_highlights
collapse/collapse
collapse/collapse_in_challenge
collapse/collapse_music_box
@@ -40,7 +40,7 @@
expect(KramdownRPF::VERSION).not_to be_nil
end
- describe 'conversions' do
+ describe 'conversions', skip: 'in favour of specification examples' do
conversion_tests.each do |test_name|
context test_name do
subject(:test_result) do
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 6efd082..985b49c 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -2,6 +2,51 @@
require 'bundler/setup'
require 'kramdown_rpf'
+require 'compare-xml'
+require 'nokogiri'
+require 'diff/lcs'
+require 'rspec/matchers'
+require 'i18n'
+
+I18n.locale = 'en'
+
+KRAMDOWN_OPTIONS = {
+ input: 'KramdownRPF',
+ parse_block_html: true,
+ syntax_highlighter: nil
+}.freeze
+
+def html_diff(actual_html, expected_html)
+ actual_lines = Nokogiri::HTML5.fragment(actual_html).to_xhtml.lines
+ expected_lines = Nokogiri::HTML5.fragment(expected_html).to_xhtml.lines
+
+ diffs = Diff::LCS.diff(actual_lines, expected_lines)
+ return '' if diffs.empty?
+
+ output = []
+ diffs.each do |hunk|
+ hunk.each do |change|
+ prefix = change.action == '+' ? "\e[32m+" : "\e[31m-"
+ output << "#{prefix} #{change.element.chomp}\e[0m"
+ end
+ end
+ output.join("\n")
+end
+
+RSpec::Matchers.define :match_html do |expected_html, **options|
+ match do |actual_html|
+ @actual_html = actual_html
+ @expected_html = expected_html
+ expected_doc = Nokogiri::HTML5.fragment(expected_html)
+ actual_doc = Nokogiri::HTML5.fragment(actual_html)
+
+ CompareXML.equivalent?(expected_doc, actual_doc, verbose: true, **options).empty?
+ end
+
+ failure_message do
+ "HTML does not match.\n#{html_diff(@actual_html, @expected_html)}"
+ end
+end
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
diff --git a/spec/specification_spec.rb b/spec/specification_spec.rb
new file mode 100644
index 0000000..c7126a0
--- /dev/null
+++ b/spec/specification_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+SPEC_MD = ENV.fetch('SPEC_MD', 'spec/kramdown_rpf-legacy-spec.md')
+
+# Parses spec.md and returns an array of:
+# { section: String, subsection: String|nil, number: Integer,
+# input: String, expected: String }
+def parse_spec(path)
+ content = File.readlines(path).map(&:chomp)
+ examples = []
+ section = 'Unknown'
+ subsection = nil
+ number = 0
+
+ in_example = false
+ example_lines = []
+
+ content.each do |line|
+ if in_example && line == in_example
+ in_example = false
+ parts = example_lines.join("\n").split(/\n·\n/, 2)
+ if parts.length == 2
+ number += 1
+ examples << {
+ section: section,
+ subsection: subsection,
+ number: number,
+ input: parts[0].strip,
+ expected: parts[1].strip
+ }
+ end
+ elsif in_example
+ example_lines << line
+ elsif line =~ /^(\#{1,6})\s*(.+)$/
+ level = Regexp.last_match(1).length
+ title = Regexp.last_match(2).strip
+ if level <= 2
+ section = title
+ subsection = nil
+ else
+ subsection = title
+ end
+ elsif line.strip =~ /^(```+)example$/
+ in_example = Regexp.last_match(1)
+ example_lines = []
+ end
+ end
+
+ examples
+end
+
+raise "Spec file not found: #{SPEC_MD}" unless File.exist?(SPEC_MD)
+
+RSpec.describe "RPF Markdown Spec: #{File.basename(SPEC_MD)}" do # rubocop:disable RSpec/DescribeClass
+ examples = parse_spec(SPEC_MD)
+ examples.group_by { |e| e[:section] }.each do |section, section_examples|
+ context section do # rubocop:disable RSpec/EmptyExampleGroup
+ section_examples.group_by { |e| e[:subsection] }.each do |subsection, sub_examples|
+ define_examples = lambda do
+ sub_examples.each do |example|
+ it "example #{example[:number]}" do
+ actual = Kramdown::Document.new(example[:input], KRAMDOWN_OPTIONS).to_html
+ expect(actual).to match_html(example[:expected])
+ end
+ end
+ end
+
+ if subsection
+ context subsection, &define_examples
+ else
+ define_examples.call
+ end
+ end
+ end
+ end
+end