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 --- +· +
+

Some hint content

+
+``` + +--- + +## 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 --- +· +
+

I need a hint

+
+
+
+
+

Hint 1

+
+
+

Hint 2

+
+
+
+ + + +
+
+
+
+
+
+``` + +--- + +## Task + +A `task` block renders as a checkable task item. Content inside is parsed as markdown. It can contain code fences, hints, and collapse blocks. + +````example +--- task --- + +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`. +--- /task --- +· +
+ +
+

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.

+
+
+```` + +### With hints + +````example +--- task --- + +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`. + +--- hints --- +--- hint --- + +Hint 1 + +--- /hint --- +--- hint --- +Hint 2 + +--- /hint --- +--- hint --- + +Hint 3 +--- /hint --- +--- hint --- +Hint 4 +--- /hint --- + +--- /hints --- + +--- /task --- +· +
+ +
+

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.

+
+

I need a hint

+
+
+
+
+

Hint 1

+
+
+

Hint 2

+
+
+

Hint 3

+
+
+

Hint 4

+
+
+
+ + + +
+
+
+
+
+
+
+
+```` + +### With ingredient + +````example +--- task --- + +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`. + +--- collapse --- +--- +title: Downloading and installing the Raspberry Pi software +--- + +Content here comes from the ingredient. + +--- /collapse --- + +--- /task --- +· +
+ +
+

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.

+
+

Downloading and installing the Raspberry Pi software

+
+

Content here comes from the ingredient.

+
+
+
+
+```` + +--- + +## Challenge + +A `challenge` block contains optional markdown content. It has no wrapping element — its content is +rendered directly into the document flow. + +```example +--- challenge --- + +## Challenge: Try something new + +Can you improve your project? + +--- /challenge --- +· +

Challenge: Try something new

+

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 --- +· +
+ hello.py +
+

+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 --- +· +
+ button_press.py +
+

+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(); + player.stars += 1; // Increase by 1 + AudioSource.PlayClipAtPoint(collectSound, transform.position); + gameObject.SetActive(false); + } + } +--- /code --- +· +
+ StarController.cs - OnTriggerEnter(Collider other) +
+

+    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 --- +· +
+

How to do something

+
+

Here is some useful information.

+
+
+``` + +### With code block in body + +```example +--- collapse --- +--- +title: Child project +--- +
+ 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']
+
+ + +--- /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() +~~~ +· +
+

Child project

+
+
+

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']
+
+
+
+
    +
  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
  2. +
+
px, 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!") + ``` +· +

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

    +
    +

    Opening IDLE

    +
    +

    [[[idle-opening]]]

    +
    +
    +
  2. +
  3. Blab la +
     from gpiozero import MotionSensor
    +
    + pir = MotionSensor(4)
    +
    +
  4. +
  5. +

    Bla bla

    +
     while True:
    +     if pir.motion_detected:
    +            print("Motion detected!")
    +
    +
  6. +
+```` + +### With HTML body + +```example +--- collapse --- +--- +title: Creating Directories on a Raspberry Pi +--- +

Creating Directories on a Raspberry Pi

+ +

There are two ways to create directories on the Raspberry Pi. The first uses the GUI, and the second uses the Terminal.

+ +

Method 1 - Using the GUI

+ +

GUI-make-directory

+ +
    +
  1. +

    Open a File Manager window by clicking on the icon in the top left corner of the screen

    + +

    file-manager

    +
  2. +
  3. In the window, right-click and select Create New… and then Folder from the context menu
  4. +
  5. In the dialogue box, type the name of your new directory and then click OK
  6. +
+ +

Method 2 - Using the Terminal

+ +

Terminal-make-directory

+ +
    +
  1. +

    Open a new Terminal window by clicking on the icon in the top left corner of the screen.

    + +

    terminal

    +
  2. +
  3. +

    You can create a new directory using the mkdir command

    + +
    +
     mkdir my-new-directory
    +
    +
    +
    +
  4. +
  5. You can list the contents of the current directory using ls
  6. +
  7. +

    To enter your new directory use the cd command

    + +
    +
     cd my-new-directory
    +
    +
    +
    +
  8. +
+ + +--- /collapse --- +· +
+

Creating Directories on a Raspberry Pi

+
+

Creating Directories on a Raspberry Pi

+

There are two ways to create directories on the Raspberry Pi. The first uses the GUI, and the second uses the Terminal.

+

Method 1 - Using the GUI

+

GUI-make-directory

+
    +
  1. +

    Open a File Manager window by clicking on the icon in the top left corner of the screen

    +

    file-manager

    +
  2. +
  3. In the window, right-click and select Create New… and then Folder from the context menu
  4. +
  5. In the dialogue box, type the name of your new directory and then click OK
  6. +
+

Method 2 - Using the Terminal

+

Terminal-make-directory

+
    +
  1. +

    Open a new Terminal window by clicking on the icon in the top left corner of the screen.

    +

    terminal

    +
  2. +
  3. +

    You can create a new directory using the mkdir command

    +
    +
    +
    +
     mkdir my-new-directory
    +
    +
    +
    +
    +
  4. +
  5. You can list the contents of the current directory using ls
  6. +
  7. +

    To enter your new directory use the cd command

    +
    +
    +
    +
     cd my-new-directory
    +
    +
    +
    +
    +
  8. +
+
+
+``` + +### Inside a challenge + +````example +## Step 5 - Record video to a file + +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. + +1. Create a variable called `filename` inside your infinite loop to store the video file name + + ```python + filename = "intruder.h264" + ``` + + In case you are wondering, `.h264` is the video format + +1. Find the line of code where you begin the camera preview and replace it with a line of code to start recording a video + + ```python + camera.start_recording(filename) + ``` + +1. Find the line of code where you stop the camera preview and replace it with a line of code to stop recording. + +--- hints --- + +--- hint --- +Look at the line of code you used to start recording and see if you can work out the code to stop recording +--- /hint --- + +--- hint --- +Here is the finished code +```python +while True: + filename = "intruder.h264" + pir.wait_for_motion() + camera.start_recording(filename) + pir.wait_for_no_motion() + camera.stop_recording() +``` +--- /hint --- + +--- /hints --- + +1. Save and run your program by pressing **F5**. Check that a file called `intruder.h264` appears in the same folder as your `parent-detector.py` file. + +--- challenge --- +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? + +--- collapse --- +--- +title: Getting the date and time in Python +image: +--- + +[[[generic-python-timestamps]]] + +--- /collapse --- + +--- /challenge --- +· +

Step 5 - Record video to a file

+

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.

+
    +
  1. +

    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

    +
  2. +
  3. +

    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)
    +
    +
  4. +
  5. +

    Find the line of code where you stop the camera preview and replace it with a line of code to stop recording.

    +
  6. +
+
+

I need a hint

+
+
+
+
+

Look at the line of code you used to start recording and see if you can work out the code to stop recording

+
+
+

Here is the finished code

+
while True:
+    filename = "intruder.h264"
+    pir.wait_for_motion()
+    camera.start_recording(filename)
+    pir.wait_for_no_motion()
+        camera.stop_recording()
+
+
+
+
+ + + +
+
+
+
+
+
+
    +
  1. Save and run your program by pressing F5. Check that a file called intruder.h264 appears in the same folder as your parent-detector.py file.
  2. +
+

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?

+
+

Getting the date and time in Python

+
+

[[[generic-python-timestamps]]]

+
+
+```` + +--- + +## Save + +The `save` tag renders a "Save your project" panel. It takes no content. + +```example +--- save --- +· +
+

Save your project

+
+``` + +--- + +## New page + +The `new-page` tag inserts a print page break. + +```example +First Page + +--- new-page --- + +Second Page +· +

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.

+
+``` + +--- + +## Print only + +Content inside a `print-only` block is only visible when printing. + +```example +--- print-only --- +This only appears in print. +--- /print-only --- +· +
+

This only appears in print.

+
+``` + +--- + +## Quiz + +A `quiz` block renders a simple radio button poll. The question is defined in YAML front matter and +the choices are a markdown list of radio items using `( )` notation. + +```example +--- quiz --- +--- +question: How are you feeling? +--- + +- ( ) Good +- ( ) Bad +- ( ) Okay + +--- /quiz --- +· +
+
+

How are you feeling?

+
+ + + + + + +
+
+
+
+``` + +--- + +## Knowledge quiz question + +A `question` block renders a self-marking quiz question. It contains a `choices` block with radio +items using `( )` for incorrect and `(x)` for the correct answer. Optional `feedback` blocks provide +per-choice feedback. + +### Simple question + +```example +--- question --- + +What is 2 + 2? + +--- choices --- + +- ( ) 3 +- (x) 4 +- ( ) 5 + +--- /choices --- + +--- /question --- +· +
+
+ Question +
+

What is 2 + 2?

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+``` + +### With legend front matter + +The legend can be overridden via a YAML front matter section inside the `question` block. + +```example +--- question --- + +--- +legend: Question 1 of 3 +--- + +What is 2 + 2? + +--- choices --- + +- ( ) 3 +- (x) 4 +- ( ) 5 + +--- /choices --- + +--- /question --- +· +
+
+ Question 1 of 3 +
+

What is 2 + 2?

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+``` + +### With per-choice feedback + +```example +--- question --- + +Is the sky blue? + +--- choices --- + +- (x) Yes + +- ( ) No + + --- feedback --- + Look outside on a clear day. + --- /feedback --- + +--- /choices --- + +--- /question --- +· +
+
+ Question +
+

Is the sky blue?

+
+
+
+ + +
+
+ + +
+
+
+ +
+``` + +### With single feedback + +```example +--- question --- + +What food will the bug reach when these instructions are followed? + +![A bug in a crossword-like maze with various bits of food scattered around](./img/q2.svg) + +1. Forward +2. Forward +3. Forward +4. Turn left +5. Forward +6. Forward +7. Turn right +8. Forward +9. Forward +10. Turn right +11. Forward +12. Forward + +--- choices --- + +--- feedback --- +Follow the instructions one at a time. Which food item does the bug reach? +--- /feedback --- + +- (x) Apple +- ( ) Banana +- ( ) Orange +- ( ) Doughnut + +--- /choices --- + +--- /question --- +· +
+
+ Question +
+

What food will the bug reach when these instructions are followed?

+

A bug in a crossword-like maze with various bits of food scattered around

+
    +
  1. Forward
  2. +
  3. Forward
  4. +
  5. Forward
  6. +
  7. Turn left
  8. +
  9. Forward
  10. +
  11. Forward
  12. +
  13. Turn right
  14. +
  15. Forward
  16. +
  17. Forward
  18. +
  19. Turn right
  20. +
  21. Forward
  22. +
  23. Forward
  24. +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+``` + +### With blocks in feedback + +````example +--- question --- + +A dog sprite in Scratch has the following code: + +![A dog with three scratch blocks](./images/q1.svg) + +How would you get the dog sprite to change size? + +--- choices --- + +- ( ) Press the 'space' key + + --- feedback --- + What code is attached to the + ```blocks3 + when [space v] key pressed + ``` + event block? + --- /feedback --- + +- ( ) Make a loud noise + + --- feedback --- + What code is attached to the + ```blocks3 + when [loudness v] > 10 :: events hat + ``` + event block? + --- /feedback --- + +- ( ) Click the green flag + + --- feedback --- + What code is attached to the + ```blocks3 + when flag clicked + ``` + event block? + --- /feedback --- + +- (x) Click on the dog sprite + +--- /choices --- + +--- /question --- +· +
+
+ Question +
+

A dog sprite in Scratch has the following code:

+

A dog with three scratch blocks

+

How would you get the dog sprite to change size?

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+```` + +--- + +## Microbit code block + +A fenced code block with language `microbit` renders with the `language-microbit` class for the +Microbit simulator widget. + +````example +```microbit +let x = 5 +``` +· +
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