From 2dfb268d4153ae5a92360d251c3b3b3953b6073c Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:54:00 +0800
Subject: [PATCH 01/10] Create ReadME.md
---
json-assignment-validator/ReadME.md | 229 ++++++++++++++++++++++++++++
1 file changed, 229 insertions(+)
create mode 100644 json-assignment-validator/ReadME.md
diff --git a/json-assignment-validator/ReadME.md b/json-assignment-validator/ReadME.md
new file mode 100644
index 00000000..a005f470
--- /dev/null
+++ b/json-assignment-validator/ReadME.md
@@ -0,0 +1,229 @@
+# R-U-Certified JSON Validator
+
+A validation tool developed by Ting Shian to assess whether participants who attended Isomer training have successfully met the assignment requirements.
+
+The tool evaluates submitted JSON files against predefined criteria and automatically generates a CSV report summarising the validation outcomes.
+
+---
+
+## ๐ Project Structure
+
+```bash
+r-u-certified/
+โ
+โโโ marking.py # Main validation script
+โโโ assignment-submission/ # Folder for JSON submissions
+โโโ test-examples/ # Example JSON files (pass / fail samples)
+โโโ reports/ # Auto-generated CSV reports (created at runtime)
+โโโ .git/ # Git metadata
+```
+
+---
+
+## ๐ Features
+
+The validator checks the following requirements:
+
+### 1๏ธโฃ Page-Level Validation
+
+* Page title extraction
+* Summary must not be empty
+* Summary must not use default placeholder text
+
+### 2๏ธโฃ Content-Level Validation
+
+* Content array must not be empty
+
+### 3๏ธโฃ Headings
+
+* First heading must be **Level 2**
+* Heading hierarchy cannot skip levels
+
+### 4๏ธโฃ Prose Blocks
+
+* Minimum **2 prose blocks** required
+* At least one prose block must contain:
+
+ * An unordered list
+ * An ordered list
+
+### 5๏ธโฃ Nested Lists
+
+* Unordered lists must contain nested sub-lists
+* Ordered lists must contain nested sub-lists
+
+### 6๏ธโฃ Tables
+
+* At least one table required
+* Each table must:
+
+ * Have a non-empty caption
+ * Not use default caption text
+ * Include table headers (`tableHeader`)
+ * Include table cells (`tableCell`)
+
+### 7๏ธโฃ Accordions
+
+* Exactly **2 accordions** required
+* Each accordion must have a non-empty summary
+
+### 8๏ธโฃ Images
+
+* At least one image required
+* Image must:
+
+ * Have non-empty alt text
+ * Not use placeholder alt text
+ * Not use placeholder image source
+
+### 9๏ธโฃ Infocards Component
+
+* Infocards component must exist
+* Each card must contain:
+
+ * Title
+ * Image alt text
+ * Image URL
+
+---
+
+## ๐ Requirements
+
+* Python 3.8+
+* No external dependencies (standard library only)
+
+Modules used:
+
+* `json`
+* `os`
+* `sys`
+* `csv`
+* `pathlib`
+* `datetime`
+
+---
+
+## โถ๏ธ How to Use
+
+### Exporting Files from Isomer Studio (Sandbox)
+
+To validate assignment submissions:
+
+ - An engineer must export the page JSON files from the Isomer Studio Sandbox environment.
+ - Create an empty assignment-submission folder
+
- Place the exported JSON files into the assignment-submission/ folder.
+ - Run the validation script against that folder.
+
+
+This ensures that the submitted files are evaluated using the same structured validation criteria.
+
+### Validate JSON files in a folder
+
+```bash
+python marking.py assignment-submission
+```
+
+---
+
+## ๐ Output
+
+### Console Output
+
+The script prints:
+
+* PASS / FAIL per file
+* Detailed validation errors
+* Summary statistics
+
+Example:
+
+```bash
+Found 3 JSON file(s) to validate
+
+โ FAIL: example.json
+ - First heading must be level 2
+ - Missing infocards component
+
+SUMMARY:
+ Total files: 3
+ โ
Passed: 1
+ โ Failed: 2
+```
+
+---
+
+### CSV Report
+
+After validation, a CSV report is automatically generated in the `reports/` folder.
+
+Filename format:
+
+```bash
+_report_YYYYMMDD_HHMMSS.csv
+```
+
+CSV Structure:
+
+| Name | Status | Errors |
+| ---------- | ----------- | ----------------- |
+| Page Title | PASS / FAIL | First error |
+| | | Additional errors |
+
+---
+
+## ๐งช Test Examples
+
+Located in:
+
+```bash
+test-examples/
+```
+
+Includes:
+
+* `perfect-example.json`
+* `half-complete-example.json`
+* `fail-example.json`
+
+You can use these files to test how the validator behaves under different scenarios.
+
+---
+
+## ๐ Architecture Overview
+
+### Core Validation Flow
+
+1. Load JSON file
+2. Validate page-level fields
+3. Validate content structure
+4. Run component-specific validators
+5. Collect errors
+6. Generate summary + CSV report
+
+### Key Functions
+
+* `validate_page_json()` โ Runs full validation pipeline
+* `validate_multiple_files()` โ Handles directory validation
+* `generate_csv_report()` โ Creates structured CSV output
+* `find_content_items()` โ Recursive utility for locating content types
+
+---
+
+## ๐ฏ Intended Use Cases
+
+* Assignment marking automation
+* Structured content validation
+* JSON schema compliance checking
+* Batch validation for content migration workflows
+
+---
+
+## ๐ฌ Extending the Validator
+
+To add new validation rules:
+
+1. Create a new validation function
+2. Append its errors inside `validate_page_json()`
+3. Ensure error messages are clear and actionable
+
+---
From 0afc551daf64317ffa7d0ae5bb86e82340aaf7b9 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:54:31 +0800
Subject: [PATCH 02/10] Add files via upload
---
json-assignment-validator/marking.py | 468 +++++++++++++++++++++++++++
1 file changed, 468 insertions(+)
create mode 100644 json-assignment-validator/marking.py
diff --git a/json-assignment-validator/marking.py b/json-assignment-validator/marking.py
new file mode 100644
index 00000000..401c36a7
--- /dev/null
+++ b/json-assignment-validator/marking.py
@@ -0,0 +1,468 @@
+# marking.py
+import json
+import os
+import sys
+import csv
+from pathlib import Path
+from datetime import datetime
+
+def find_content_items(content, item_type):
+ """Recursively find all items of a specific type in content"""
+ items = []
+
+ if isinstance(content, dict):
+ if content.get('type') == item_type:
+ items.append(content)
+ for value in content.values():
+ items.extend(find_content_items(value, item_type))
+ elif isinstance(content, list):
+ for item in content:
+ items.extend(find_content_items(item, item_type))
+
+ return items
+
+def validate_heading_hierarchy(content):
+ """Validate that headings start at level 2 and follow hierarchy across entire document"""
+ errors = []
+ headings = find_content_items(content, 'heading')
+
+ if not headings:
+ return [] # No headings found, skip validation
+
+ # Check if first heading is level 2
+ if headings[0].get('attrs', {}).get('level') != 2:
+ errors.append("First heading must be level 2")
+
+ # Check hierarchy - each heading should not skip levels
+ for i in range(len(headings) - 1):
+ current_level = headings[i].get('attrs', {}).get('level', 0)
+ next_level = headings[i + 1].get('attrs', {}).get('level', 0)
+
+ # Next heading can be same level, one level deeper, or any level shallower
+ if next_level > current_level + 1:
+ errors.append(f"Heading hierarchy error: jumped from level {current_level} to level {next_level}")
+
+ return errors
+
+def validate_prose_blocks_with_lists(content):
+ """Validate at least 2 prose blocks, one with unordered list and one with ordered list"""
+ errors = []
+
+ if not isinstance(content, list):
+ return errors
+
+ # Find all prose blocks
+ prose_blocks = [item for item in content if isinstance(item, dict) and item.get('type') == 'prose']
+
+ if len(prose_blocks) < 2:
+ errors.append(f"Need at least 2 prose blocks (found: {len(prose_blocks)})")
+ return errors
+
+ # Check for unordered list in at least one prose block
+ has_unordered_in_prose = False
+ for prose in prose_blocks:
+ prose_content = prose.get('content', [])
+ if find_content_items(prose_content, 'unorderedList'):
+ has_unordered_in_prose = True
+ break
+
+ if not has_unordered_in_prose:
+ errors.append("At least one prose block must contain an unordered list")
+
+ # Check for ordered list in at least one prose block
+ has_ordered_in_prose = False
+ for prose in prose_blocks:
+ prose_content = prose.get('content', [])
+ if find_content_items(prose_content, 'orderedList'):
+ has_ordered_in_prose = True
+ break
+
+ if not has_ordered_in_prose:
+ errors.append("At least one prose block must contain an ordered list")
+
+ return errors
+
+def validate_nested_lists(content):
+ """Validate that lists have nested sub-lists"""
+ errors = []
+
+ # Check unordered lists
+ unordered_lists = find_content_items(content, 'unorderedList')
+ has_nested_unordered = False
+ for ul in unordered_lists:
+ for item in ul.get('content', []):
+ if isinstance(item, dict) and item.get('type') == 'listItem':
+ # Check if this list item contains a nested list
+ item_content = item.get('content', [])
+ if find_content_items(item_content, 'unorderedList'):
+ has_nested_unordered = True
+ break
+ if has_nested_unordered:
+ break
+
+ if not has_nested_unordered:
+ errors.append("Unordered list must have nested sub-lists")
+
+ # Check ordered lists
+ ordered_lists = find_content_items(content, 'orderedList')
+ has_nested_ordered = False
+ for ol in ordered_lists:
+ for item in ol.get('content', []):
+ if isinstance(item, dict) and item.get('type') == 'listItem':
+ # Check if this list item contains a nested list
+ item_content = item.get('content', [])
+ if find_content_items(item_content, 'orderedList'):
+ has_nested_ordered = True
+ break
+ if has_nested_ordered:
+ break
+
+ if not has_nested_ordered:
+ errors.append("Ordered list must have nested sub-lists")
+
+ return errors
+
+def validate_table_structure_and_caption(content):
+ """Validate that tables have headers, cells, and proper captions"""
+ errors = []
+ tables = find_content_items(content, 'table')
+
+ if not tables:
+ errors.append("Missing table (at least 1 required)")
+ return errors
+
+ for table_idx, table in enumerate(tables, 1):
+ # Validate caption
+ caption = table.get('attrs', {}).get('caption', '')
+
+ if not caption or caption.strip() == '':
+ errors.append(f"Table {table_idx}: Caption cannot be empty")
+ elif caption == "Table caption":
+ errors.append(f"Table {table_idx}: Caption cannot be the default text 'Table caption'")
+
+ # Validate table structure (has headers and cells)
+ has_header = False
+ has_cell = False
+
+ table_content = table.get('content', [])
+ for row in table_content:
+ if isinstance(row, dict) and row.get('type') == 'tableRow':
+ row_content = row.get('content', [])
+ for cell in row_content:
+ if isinstance(cell, dict):
+ if cell.get('type') == 'tableHeader':
+ has_header = True
+ elif cell.get('type') == 'tableCell':
+ has_cell = True
+
+ if not has_header:
+ errors.append(f"Table {table_idx}: Missing table headers (tableHeader)")
+ if not has_cell:
+ errors.append(f"Table {table_idx}: Missing table cells (tableCell)")
+
+ return errors
+
+def validate_accordions(content):
+ """Validate that exactly 2 accordions are present with non-empty summaries"""
+ errors = []
+
+ # Count accordions at the top level of content array
+ accordion_count = 0
+ accordions = []
+ if isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get('type') == 'accordion':
+ accordion_count += 1
+ accordions.append(item)
+
+ if accordion_count == 0:
+ errors.append("Missing accordions (required: 2)")
+ elif accordion_count == 1:
+ errors.append("Only 1 accordion found (required: 2)")
+ elif accordion_count > 2:
+ errors.append(f"Too many accordions found: {accordion_count} (required: 2)")
+
+ # Validate accordion summaries
+ for idx, accordion in enumerate(accordions, 1):
+ summary = accordion.get('summary', '')
+ if not summary or summary.strip() == '':
+ errors.append(f"Accordion {idx}: Summary cannot be empty")
+
+ return errors
+
+def validate_images(content):
+ """Validate that at least 1 image is present with proper alt text and non-placeholder src"""
+ errors = []
+
+ # Find all images (both standalone and within other components)
+ all_images = find_content_items(content, 'image')
+
+ if not all_images:
+ errors.append("Missing image (at least 1 required)")
+ return errors
+
+ for idx, image in enumerate(all_images, 1):
+ # Validate alt text
+ alt = image.get('alt', '')
+
+ if not alt or alt.strip() == '':
+ errors.append(f"Image {idx}: Alt text cannot be empty")
+ elif alt == "Add your alt text here":
+ errors.append(f"Image {idx}: Alt text cannot be the default text 'Add your alt text here'")
+
+ # Validate src
+ src = image.get('src', '')
+
+ if src == "/placeholder_no_image.png":
+ errors.append(f"Image {idx}: Image source cannot be the placeholder '/placeholder_no_image.png'")
+
+ return errors
+
+def validate_infocards(content):
+ """Validate that infocards component is present and each card has proper title, imageAlt, and imageUrl"""
+ errors = []
+ infocards = find_content_items(content, 'infocards')
+
+ # Check if infocards component exists
+ if not infocards:
+ errors.append("Missing infocards component")
+ return errors
+
+ for infocard_idx, infocard in enumerate(infocards, 1):
+ cards = infocard.get('cards', [])
+
+ if not cards:
+ errors.append(f"Infocards {infocard_idx}: No cards found")
+ continue
+
+ for card_idx, card in enumerate(cards, 1):
+ # Validate title
+ title = card.get('title', '')
+ if not title or title.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Title cannot be empty")
+
+ # Validate imageAlt
+ image_alt = card.get('imageAlt', '')
+ if not image_alt or image_alt.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be empty")
+ elif image_alt == "A placeholder image.":
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be the placeholder text 'A placeholder image.'")
+
+ # Validate imageUrl
+ image_url = card.get('imageUrl', '')
+ if not image_url or image_url.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image URL cannot be empty")
+
+ return errors
+
+def validate_content_not_empty(content):
+ """Validate that content array is not empty"""
+ errors = []
+
+ if not content or len(content) == 0:
+ errors.append("Content array cannot be empty")
+
+ return errors
+
+def validate_page_json(json_data, filename):
+ """Validate page JSON with all requirements"""
+ errors = []
+ page_title = "N/A"
+
+ try:
+ # Extract page title
+ page_title = json_data['page'].get('title', 'N/A')
+
+ # Validate summary
+ summary = json_data['page']['contentPageHeader']['summary']
+
+ if summary.strip() == '':
+ errors.append("Summary cannot be empty or contain only whitespace")
+ elif summary == "This is the page summary":
+ errors.append("Summary cannot be the default text 'This is the page summary'")
+
+ # Get content for validation
+ content = json_data.get('content', [])
+
+ # Validate content is not empty
+ content_empty_errors = validate_content_not_empty(content)
+ errors.extend(content_empty_errors)
+
+ # Only continue validation if content is not empty
+ if not content_empty_errors:
+ # Validate heading hierarchy across entire document
+ heading_errors = validate_heading_hierarchy(content)
+ errors.extend(heading_errors)
+
+ # Validate prose blocks with lists
+ prose_errors = validate_prose_blocks_with_lists(content)
+ errors.extend(prose_errors)
+
+ # Validate nested lists
+ nested_list_errors = validate_nested_lists(content)
+ errors.extend(nested_list_errors)
+
+ # Validate tables (at least 1, validate all if multiple)
+ table_errors = validate_table_structure_and_caption(content)
+ errors.extend(table_errors)
+
+ # Validate accordions (exactly 2)
+ accordion_errors = validate_accordions(content)
+ errors.extend(accordion_errors)
+
+ # Validate images (at least 1, validate all if multiple)
+ image_errors = validate_images(content)
+ errors.extend(image_errors)
+
+ # Validate infocards
+ infocard_errors = validate_infocards(content)
+ errors.extend(infocard_errors)
+
+ except KeyError as e:
+ errors.append(f"Missing required field: {str(e)}")
+
+ return {
+ 'valid': len(errors) == 0,
+ 'errors': errors,
+ 'title': page_title
+ }
+
+def validate_json_file(file_path):
+ """Validate a single JSON file"""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ json_data = json.load(f)
+
+ filename = os.path.basename(file_path)
+ return validate_page_json(json_data, filename)
+
+ except json.JSONDecodeError as e:
+ return {
+ 'valid': False,
+ 'errors': [f"Invalid JSON format: {str(e)}"],
+ 'title': 'N/A'
+ }
+ except Exception as e:
+ return {
+ 'valid': False,
+ 'errors': [f"Error reading file: {str(e)}"],
+ 'title': 'N/A'
+ }
+
+def generate_csv_report(results, directory):
+ """Generate CSV report in reports folder with errors on separate rows"""
+ # Create reports folder if it doesn't exist
+ reports_dir = Path('reports')
+ reports_dir.mkdir(exist_ok=True)
+
+ # Create report filename based on folder name
+ folder_name = os.path.basename(os.path.abspath(directory))
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ report_filename = reports_dir / f"{folder_name}_report_{timestamp}.csv"
+
+ # Write CSV report
+ with open(report_filename, 'w', newline='', encoding='utf-8') as csvfile:
+ fieldnames = ['Name', 'Status', 'Errors']
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+
+ writer.writeheader()
+ for file_result in results['files']:
+ if file_result['errors']:
+ # Write first error with name and status
+ writer.writerow({
+ 'Name': file_result['title'],
+ 'Status': 'PASS' if file_result['valid'] else 'FAIL',
+ 'Errors': file_result['errors'][0]
+ })
+ # Write remaining errors with empty name and status
+ for error in file_result['errors'][1:]:
+ writer.writerow({
+ 'Name': '',
+ 'Status': '',
+ 'Errors': error
+ })
+ else:
+ # No errors, just write the single row
+ writer.writerow({
+ 'Name': file_result['title'],
+ 'Status': 'PASS',
+ 'Errors': ''
+ })
+
+ print(f"\n๐ CSV report generated: {report_filename}")
+ return str(report_filename)
+
+def validate_multiple_files(directory):
+ """Validate all JSON files in a directory"""
+ results = {
+ 'total': 0,
+ 'passed': 0,
+ 'failed': 0,
+ 'files': []
+ }
+
+ try:
+ directory_path = Path(directory)
+ json_files = list(directory_path.glob('*.json'))
+
+ if not json_files:
+ print(f"No JSON files found in {directory}")
+ return results
+
+ print(f"Found {len(json_files)} JSON file(s) to validate\n")
+ print("=" * 60)
+
+ for file_path in json_files:
+ filename = file_path.name
+ result = validate_json_file(file_path)
+
+ results['total'] += 1
+
+ if result['valid']:
+ results['passed'] += 1
+ print(f"โ
PASS: {filename} (Title: {result['title']})")
+ else:
+ results['failed'] += 1
+ print(f"โ FAIL: {filename} (Title: {result['title']})")
+ for error in result['errors']:
+ print(f" - {error}")
+
+ results['files'].append({
+ 'filename': filename,
+ 'title': result['title'],
+ 'path': str(file_path),
+ 'valid': result['valid'],
+ 'errors': result['errors']
+ })
+
+ # Summary
+ print("\n" + "=" * 60)
+ print("SUMMARY:")
+ print(f" Total files: {results['total']}")
+ print(f" โ
Passed: {results['passed']}")
+ print(f" โ Failed: {results['failed']}")
+ print("=" * 60)
+
+ # Generate CSV report
+ if results['total'] > 0:
+ generate_csv_report(results, directory)
+
+ return results
+
+ except Exception as e:
+ print(f"Error reading directory: {str(e)}")
+ return results
+
+def main():
+ """Main execution"""
+ directory = sys.argv[1] if len(sys.argv) > 1 else '.'
+
+ print(f"Validating JSON files in: {os.path.abspath(directory)}\n")
+
+ results = validate_multiple_files(directory)
+
+ # Exit with error code if any files failed
+ sys.exit(1 if results['failed'] > 0 else 0)
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file
From 9f3f61116537d2095bdaa202b069e4e53de777f6 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:55:21 +0800
Subject: [PATCH 03/10] Create test-examples.md
---
json-assignment-validator/test-examples.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 json-assignment-validator/test-examples.md
diff --git a/json-assignment-validator/test-examples.md b/json-assignment-validator/test-examples.md
new file mode 100644
index 00000000..038d718d
--- /dev/null
+++ b/json-assignment-validator/test-examples.md
@@ -0,0 +1 @@
+testing
From 5790e10e83fd02be3bf3acbc0f25a950cd1eb3dd Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:55:51 +0800
Subject: [PATCH 04/10] Rename json-assignment-validator/test-examples.md to
json-assignment-validator/test-examples/test-examples.md
---
json-assignment-validator/{ => test-examples}/test-examples.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename json-assignment-validator/{ => test-examples}/test-examples.md (100%)
diff --git a/json-assignment-validator/test-examples.md b/json-assignment-validator/test-examples/test-examples.md
similarity index 100%
rename from json-assignment-validator/test-examples.md
rename to json-assignment-validator/test-examples/test-examples.md
From 88bbf28f0fb0f2299531a9141a7670d0c7c98dc7 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:56:19 +0800
Subject: [PATCH 05/10] Add files via upload
---
.../test-examples/fail-example.json | 773 +++++++++++++++++
.../test-examples/half-complete-example.json | 514 ++++++++++++
.../test-examples/perfect-example.json | 786 ++++++++++++++++++
3 files changed, 2073 insertions(+)
create mode 100644 json-assignment-validator/test-examples/fail-example.json
create mode 100644 json-assignment-validator/test-examples/half-complete-example.json
create mode 100644 json-assignment-validator/test-examples/perfect-example.json
diff --git a/json-assignment-validator/test-examples/fail-example.json b/json-assignment-validator/test-examples/fail-example.json
new file mode 100644
index 00000000..b6db9218
--- /dev/null
+++ b/json-assignment-validator/test-examples/fail-example.json
@@ -0,0 +1,773 @@
+{
+ "comment": "Example FAIL: user attempted the task but they were missing components",
+ "sandboxReference": "https://sandbox-studio.isomer.gov.sg/sites/117/pages/21640",
+ "page": {
+ "title": "Fail example",
+ "permalink": "/moh-upec/chang-wei-si/my-page",
+ "lastModified": "2026-01-20T03:06:25.279Z",
+ "contentPageHeader": {
+ "summary": "This is a sample page, used for Isomer studio training",
+ "buttonUrl": "https://google.com",
+ "buttonLabel": "A CTA button can be added here",
+ "showThumbnail": false
+ }
+ },
+ "layout": "content",
+ "content": [
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section Heading One",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "The government is committed to improving the lives of citizens through various services and initiatives. Key focus areas include:",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Ensuring access to quality healthcare",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Implementing safety measures in public spaces",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Promoting community wellness programmes",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing the education system at all levels",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Providing skills upgrading opportunities",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Supporting research and innovation",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ },
+ {
+ "type": "table",
+ "attrs": {
+ "caption": "Table 1: Information on Header 1, 2, and 3"
+ },
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 2",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 3",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 2",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 3",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 4",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 5",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 6",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion 1"
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion 2"
+ },
+ {
+ "alt": "A sun image",
+ "src": "/117/d49560da-5430-4f2f-acb6-144c43125884/download.jpeg",
+ "size": "default",
+ "type": "image",
+ "caption": "A sun"
+ },
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section heading two",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "The government is committed to improving the lives of citizens through various services and initiatives. Key focus areas include:",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Public Health and Safety",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Ensuring access to quality healthcare",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Implementing safety measures in public spaces",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Promoting community wellness programmes",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Education and Lifelong Learning",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing the education system at all levels",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Providing skills upgrading opportunities",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Supporting research and innovation",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Infrastructure Development",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Improving public transportation networks",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Upgrading community facilities",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing digital infrastructure for better connectivity",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "This is a title of the Infocards component",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "This is an optional subtitle for the Infocards component",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ }
+ ],
+ "version": "0.1.0"
+}
\ No newline at end of file
diff --git a/json-assignment-validator/test-examples/half-complete-example.json b/json-assignment-validator/test-examples/half-complete-example.json
new file mode 100644
index 00000000..761ef72d
--- /dev/null
+++ b/json-assignment-validator/test-examples/half-complete-example.json
@@ -0,0 +1,514 @@
+{
+ "comment": "Example FAIL: user attempted the task but did not complete all required steps",
+ "sandboxReference": "https://sandbox-studio.isomer.gov.sg/sites/118/pages/21866",
+ "page": {
+ "title": "Half complete example",
+ "permalink": "/my-ostin/my-page",
+ "lastModified": "2026-02-10T03:00:45.955Z",
+ "contentPageHeader": {
+ "summary": "This is a sample page, used for Isomer studio training",
+ "buttonUrl": "mailto:Blah",
+ "buttonLabel": "A CTA button can be added here ",
+ "showThumbnail": false
+ }
+ },
+ "layout": "content",
+ "content": [
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section heading one",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "This is the content under this section heading one ",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Next paragraph under this section with",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Bullet 1 to elaborate ",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "And sub bullets to expand ",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "And another sub bullet",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "bullet 2 ",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "And so on",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ },
+ {
+ "type": "table",
+ "attrs": {
+ "caption": "Table 1: Information on Header 1, 2 and 3"
+ },
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 2",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 3",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": null
+ }
+ }
+ ]
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "This is the content under Accordion 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion 1"
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "This appears under Accordion 2",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion 2"
+ },
+ {
+ "alt": "This is a placeholder with no image ",
+ "src": "/placeholder_no_image.png",
+ "size": "default",
+ "type": "image",
+ "caption": "A brief description of this non-existent image for us to practice on"
+ },
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section heading two",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "More text for this section",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Blah blah ",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "infocards",
+ "cards": [
+ {
+ "url": "https://www.google.com",
+ "title": "This is the first card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/placeholder_no_image.png",
+ "description": "1st pic "
+ },
+ {
+ "url": "https://www.google.com",
+ "title": "This is the second card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/placeholder_no_image.png",
+ "description": "Pic 2"
+ },
+ {
+ "url": "https://www.google.com",
+ "title": "This is the third card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/placeholder_no_image.png",
+ "description": "Pic 3"
+ }
+ ],
+ "title": "Infocards for 3 key events/CTA",
+ "variant": "cardsWithImages",
+ "subtitle": "These are our latest news",
+ "maxColumns": "3"
+ }
+ ],
+ "version": "0.1.0"
+}
\ No newline at end of file
diff --git a/json-assignment-validator/test-examples/perfect-example.json b/json-assignment-validator/test-examples/perfect-example.json
new file mode 100644
index 00000000..14b282e6
--- /dev/null
+++ b/json-assignment-validator/test-examples/perfect-example.json
@@ -0,0 +1,786 @@
+{
+ "comment": "Example PASS: user attempted the task and completed all required steps",
+ "sandboxReference": "https://sandbox-studio.isomer.gov.sg/sites/118/pages/21857",
+ "page": {
+ "title": "Perfect example",
+ "permalink": "/jun-kun/my-page",
+ "lastModified": "2026-01-21T02:37:41.307Z",
+ "contentPageHeader": {
+ "summary": "This is a sample page, used for Isomer studio training.",
+ "buttonUrl": "https://isomer.gov.sg/",
+ "buttonLabel": "A CTA button can be added here",
+ "showThumbnail": false
+ }
+ },
+ "layout": "content",
+ "content": [
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section heading one",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "The government is committed to improving the lives of citizens through various services and initiatives. Key focus areas include:",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Ensuring access to quality healthcare",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "unorderedList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Implementing safety measures in public spaces",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Promoting community wellness programmes",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing the education system at all levels",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Providing skills upgrading opportunities",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Supporting research and innovation",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "attrs": {
+ "caption": "Table 1: Information on Header 1, 2, and 3"
+ },
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 1",
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 2",
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableHeader",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Header 3",
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 1",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 2",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 3",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableRow",
+ "content": [
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 4",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 5",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "tableCell",
+ "attrs": {
+ "colspan": 1,
+ "rowspan": 1,
+ "colwidth": null
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Item 6",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion 1"
+ },
+ {
+ "type": "accordion",
+ "details": {
+ "type": "prose",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Government services play a vital role in supporting citizens and enhancing community well-being. From healthcare and education to infrastructure and public safety, these services form the backbone of our society.",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ "summary": "Accordion"
+ },
+ {
+ "alt": "Please upload an image of your choice here and write an alt text for the image",
+ "src": "/118/39a1dca3-8d3d-40ab-870a-6e1601367b95/image-27cd6af3.webp",
+ "size": "default",
+ "type": "image"
+ },
+ {
+ "type": "prose",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "dir": "ltr",
+ "level": 2
+ },
+ "content": [
+ {
+ "text": "Section heading two",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "The government is committed to improving the lives of citizens through various services and initiatives. Key focus areas include:",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Public Health and Safety",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Ensuring access to quality healthcare",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Implementing safety measures in public spaces",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Promoting community wellness programmes",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Education and Lifelong Learning",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing the education system at all levels",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Providing skills upgrading opportunities",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Supporting research and innovation",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Infrastructure Development",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Improving public transportation networks",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Upgrading community facilities",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "type": "orderedList",
+ "attrs": {
+ "type": null,
+ "start": 1
+ },
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "dir": "ltr"
+ },
+ "content": [
+ {
+ "text": "Enhancing digital infrastructure for better connectivity",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "infocards",
+ "cards": [
+ {
+ "url": "https://isomer.gov.sg/",
+ "title": "This is the first card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/118/c161fbee-b755-43e7-8393-a92b8d002ccc/image-27cd6af3.webp"
+ },
+ {
+ "url": "https://isomer.gov.sg/",
+ "title": "This is the second card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/118/fb741c5a-8d3a-4e45-82b6-dbe873b7c9fd/image-27cd6af3.webp"
+ },
+ {
+ "url": "https://isomer.gov.sg/",
+ "title": "This is the third card",
+ "imageAlt": "This is the alt text",
+ "imageFit": "cover",
+ "imageUrl": "/118/4502e9a0-b320-46df-83b3-b69ab13a6073/image-27cd6af3.webp"
+ }
+ ],
+ "title": "This is a title of the Infocards component",
+ "variant": "cardsWithImages",
+ "subtitle": "This is an optional subtitle for the Infocards component",
+ "maxColumns": "3"
+ },
+ {
+ "url": "https://www.youtube.com/embed/dQw4w9WgXcQ?si=ggGGn4uvFWAIelWD",
+ "type": "video",
+ "title": "Rick Astley - Never Gonna Give You Up"
+ },
+ {
+ "type": "blockquote",
+ "quote": "Let me gooooo",
+ "source": "Elsa",
+ "imageAlt": "Portrait of Huaying Zhu"
+ }
+ ],
+ "version": "0.1.0"
+}
\ No newline at end of file
From 036e8e2ed72f3bd96514cd3b1fe302c826ba7716 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 17:56:48 +0800
Subject: [PATCH 06/10] Delete
json-assignment-validator/test-examples/test-examples.md
---
json-assignment-validator/test-examples/test-examples.md | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 json-assignment-validator/test-examples/test-examples.md
diff --git a/json-assignment-validator/test-examples/test-examples.md b/json-assignment-validator/test-examples/test-examples.md
deleted file mode 100644
index 038d718d..00000000
--- a/json-assignment-validator/test-examples/test-examples.md
+++ /dev/null
@@ -1 +0,0 @@
-testing
From f6b41057dddb57757bd9b5e973091a3cc0757ca2 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Thu, 12 Feb 2026 11:48:19 +0800
Subject: [PATCH 07/10] Delete json-assignment-validator/ReadME.md
---
json-assignment-validator/ReadME.md | 229 ----------------------------
1 file changed, 229 deletions(-)
delete mode 100644 json-assignment-validator/ReadME.md
diff --git a/json-assignment-validator/ReadME.md b/json-assignment-validator/ReadME.md
deleted file mode 100644
index a005f470..00000000
--- a/json-assignment-validator/ReadME.md
+++ /dev/null
@@ -1,229 +0,0 @@
-# R-U-Certified JSON Validator
-
-A validation tool developed by Ting Shian to assess whether participants who attended Isomer training have successfully met the assignment requirements.
-
-The tool evaluates submitted JSON files against predefined criteria and automatically generates a CSV report summarising the validation outcomes.
-
----
-
-## ๐ Project Structure
-
-```bash
-r-u-certified/
-โ
-โโโ marking.py # Main validation script
-โโโ assignment-submission/ # Folder for JSON submissions
-โโโ test-examples/ # Example JSON files (pass / fail samples)
-โโโ reports/ # Auto-generated CSV reports (created at runtime)
-โโโ .git/ # Git metadata
-```
-
----
-
-## ๐ Features
-
-The validator checks the following requirements:
-
-### 1๏ธโฃ Page-Level Validation
-
-* Page title extraction
-* Summary must not be empty
-* Summary must not use default placeholder text
-
-### 2๏ธโฃ Content-Level Validation
-
-* Content array must not be empty
-
-### 3๏ธโฃ Headings
-
-* First heading must be **Level 2**
-* Heading hierarchy cannot skip levels
-
-### 4๏ธโฃ Prose Blocks
-
-* Minimum **2 prose blocks** required
-* At least one prose block must contain:
-
- * An unordered list
- * An ordered list
-
-### 5๏ธโฃ Nested Lists
-
-* Unordered lists must contain nested sub-lists
-* Ordered lists must contain nested sub-lists
-
-### 6๏ธโฃ Tables
-
-* At least one table required
-* Each table must:
-
- * Have a non-empty caption
- * Not use default caption text
- * Include table headers (`tableHeader`)
- * Include table cells (`tableCell`)
-
-### 7๏ธโฃ Accordions
-
-* Exactly **2 accordions** required
-* Each accordion must have a non-empty summary
-
-### 8๏ธโฃ Images
-
-* At least one image required
-* Image must:
-
- * Have non-empty alt text
- * Not use placeholder alt text
- * Not use placeholder image source
-
-### 9๏ธโฃ Infocards Component
-
-* Infocards component must exist
-* Each card must contain:
-
- * Title
- * Image alt text
- * Image URL
-
----
-
-## ๐ Requirements
-
-* Python 3.8+
-* No external dependencies (standard library only)
-
-Modules used:
-
-* `json`
-* `os`
-* `sys`
-* `csv`
-* `pathlib`
-* `datetime`
-
----
-
-## โถ๏ธ How to Use
-
-### Exporting Files from Isomer Studio (Sandbox)
-
-To validate assignment submissions:
-
- - An engineer must export the page JSON files from the Isomer Studio Sandbox environment.
- - Create an empty assignment-submission folder
-
- Place the exported JSON files into the assignment-submission/ folder.
- - Run the validation script against that folder.
-
-
-This ensures that the submitted files are evaluated using the same structured validation criteria.
-
-### Validate JSON files in a folder
-
-```bash
-python marking.py assignment-submission
-```
-
----
-
-## ๐ Output
-
-### Console Output
-
-The script prints:
-
-* PASS / FAIL per file
-* Detailed validation errors
-* Summary statistics
-
-Example:
-
-```bash
-Found 3 JSON file(s) to validate
-
-โ FAIL: example.json
- - First heading must be level 2
- - Missing infocards component
-
-SUMMARY:
- Total files: 3
- โ
Passed: 1
- โ Failed: 2
-```
-
----
-
-### CSV Report
-
-After validation, a CSV report is automatically generated in the `reports/` folder.
-
-Filename format:
-
-```bash
-_report_YYYYMMDD_HHMMSS.csv
-```
-
-CSV Structure:
-
-| Name | Status | Errors |
-| ---------- | ----------- | ----------------- |
-| Page Title | PASS / FAIL | First error |
-| | | Additional errors |
-
----
-
-## ๐งช Test Examples
-
-Located in:
-
-```bash
-test-examples/
-```
-
-Includes:
-
-* `perfect-example.json`
-* `half-complete-example.json`
-* `fail-example.json`
-
-You can use these files to test how the validator behaves under different scenarios.
-
----
-
-## ๐ Architecture Overview
-
-### Core Validation Flow
-
-1. Load JSON file
-2. Validate page-level fields
-3. Validate content structure
-4. Run component-specific validators
-5. Collect errors
-6. Generate summary + CSV report
-
-### Key Functions
-
-* `validate_page_json()` โ Runs full validation pipeline
-* `validate_multiple_files()` โ Handles directory validation
-* `generate_csv_report()` โ Creates structured CSV output
-* `find_content_items()` โ Recursive utility for locating content types
-
----
-
-## ๐ฏ Intended Use Cases
-
-* Assignment marking automation
-* Structured content validation
-* JSON schema compliance checking
-* Batch validation for content migration workflows
-
----
-
-## ๐ฌ Extending the Validator
-
-To add new validation rules:
-
-1. Create a new validation function
-2. Append its errors inside `validate_page_json()`
-3. Ensure error messages are clear and actionable
-
----
From 727b31ef80d5fa6aeab9faeaf1556f985cc1148c Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Thu, 12 Feb 2026 11:48:48 +0800
Subject: [PATCH 08/10] Add files via upload
---
json-assignment-validator/ReadME.md | 190 ++++++++++++++++++++++++++++
1 file changed, 190 insertions(+)
create mode 100644 json-assignment-validator/ReadME.md
diff --git a/json-assignment-validator/ReadME.md b/json-assignment-validator/ReadME.md
new file mode 100644
index 00000000..1796691d
--- /dev/null
+++ b/json-assignment-validator/ReadME.md
@@ -0,0 +1,190 @@
+# ๐ TS Marking Script (R-U-Certified)
+
+A Python-based JSON validation tool used to assess Isomer training assignment submissions.
+
+The script validates structured page JSON files against predefined rules and generates a CSV report summarising results.
+
+---
+
+## ๐ Features
+
+* Validates JSON assignment submissions
+* Displays PASS / FAIL results in console
+* Generates timestamped CSV report
+* Uses only Python standard library (no external dependencies)
+
+---
+
+## ๐ฆ Project Structure
+
+```bash
+json-assignment-validator/
+โโโ marking.py
+โโโ assignment-submission/
+โโโ test-examples/
+โโโ reports/
+```
+
+* `marking.py` โ Main validation script
+* `assignment-submission/` โ Folder containing student JSON files
+* `test-examples/` โ Sample JSON files (pass/fail examples)
+* `reports/` โ Auto-generated CSV reports
+
+---
+
+## ๐งฐ Requirements
+
+* Python 3.8+
+* Visual Studio Code (recommended)
+
+Verify Python installation:
+
+```bash
+python3 --version
+```
+
+---
+
+## ๐ Setup
+
+### 1๏ธโฃ Clone the Repository
+
+Using GitHub Desktop or Git:
+
+```bash
+git clone
+```
+
+Open the project folder in VS Code.
+
+---
+
+### 2๏ธโฃ Create Submission Folder
+
+Inside the project directory, ensure this folder exists:
+
+```bash
+assignment-submission/
+```
+
+---
+
+### 3๏ธโฃ Add Student Submissions
+
+* Export student JSON files from **Isomer Studio Sandbox**.
+* Place the exported `.json` files inside `assignment-submission/`.
+
+---
+
+## โถ๏ธ Running the Script
+
+Open the VS Code terminal and run:
+
+```bash
+python3 marking.py assignment-submission
+```
+
+If `python3` does not work, try:
+
+```bash
+python marking.py assignment-submission
+```
+
+---
+
+## ๐ Output
+
+### Console Output
+
+Example:
+
+```bash
+โ
PASS: student1.json (Title: My Page)
+โ FAIL: student2.json (Title: Home Page)
+ - Summary cannot be the default text 'This is the page summary'
+ - Missing ordered list
+```
+
+Summary:
+
+```bash
+Total files: 3
+Passed: 1
+Failed: 2
+```
+
+---
+
+### ๐ CSV Report
+
+A timestamped CSV file is generated in the `reports/` folder:
+
+```
+reports/_report_YYYYMMDD_HHMMSS.csv
+```
+
+Columns:
+
+| Name | Status | Errors |
+| ---- | ------ | ------ |
+
+* **Name** โ Extracted from page title
+* **Status** โ PASS or FAIL
+* **Errors** โ Listed per validation failure
+
+---
+
+## โ
Validation Rules
+
+The script checks:
+
+### Page Summary
+
+* Cannot be empty
+* Cannot be "This is the page summary"
+
+### Content Structure
+
+* At least 2 prose blocks
+* One unordered list
+* One ordered list
+* Nested sub-lists required
+
+### Headings
+
+* First heading must be level 2
+* Cannot skip heading levels
+
+### Tables
+
+* Minimum 1 table
+* Caption required (not default text)
+* Must contain headers and cells
+
+### Accordions
+
+* Exactly 2 required
+* Each must have a summary
+
+### Images
+
+* Minimum 1 image
+* Alt text required
+* No placeholder alt text
+* No placeholder image source
+
+### Infocards
+
+* Component must exist
+* Each card must have title, image alt text, and image URL
+
+---
+
+## ๐ Troubleshooting
+
+* Ensure all files end with `.json`
+* Read error messages carefully โ they indicate what needs fixing
+* Verify Python is installed correctly
+
+---
+
From 564af726ca9cf4b816fe9ddd7e88e5b930f31804 Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Thu, 12 Feb 2026 15:14:30 +0800
Subject: [PATCH 09/10] Delete json-assignment-validator/marking.py
---
json-assignment-validator/marking.py | 468 ---------------------------
1 file changed, 468 deletions(-)
delete mode 100644 json-assignment-validator/marking.py
diff --git a/json-assignment-validator/marking.py b/json-assignment-validator/marking.py
deleted file mode 100644
index 401c36a7..00000000
--- a/json-assignment-validator/marking.py
+++ /dev/null
@@ -1,468 +0,0 @@
-# marking.py
-import json
-import os
-import sys
-import csv
-from pathlib import Path
-from datetime import datetime
-
-def find_content_items(content, item_type):
- """Recursively find all items of a specific type in content"""
- items = []
-
- if isinstance(content, dict):
- if content.get('type') == item_type:
- items.append(content)
- for value in content.values():
- items.extend(find_content_items(value, item_type))
- elif isinstance(content, list):
- for item in content:
- items.extend(find_content_items(item, item_type))
-
- return items
-
-def validate_heading_hierarchy(content):
- """Validate that headings start at level 2 and follow hierarchy across entire document"""
- errors = []
- headings = find_content_items(content, 'heading')
-
- if not headings:
- return [] # No headings found, skip validation
-
- # Check if first heading is level 2
- if headings[0].get('attrs', {}).get('level') != 2:
- errors.append("First heading must be level 2")
-
- # Check hierarchy - each heading should not skip levels
- for i in range(len(headings) - 1):
- current_level = headings[i].get('attrs', {}).get('level', 0)
- next_level = headings[i + 1].get('attrs', {}).get('level', 0)
-
- # Next heading can be same level, one level deeper, or any level shallower
- if next_level > current_level + 1:
- errors.append(f"Heading hierarchy error: jumped from level {current_level} to level {next_level}")
-
- return errors
-
-def validate_prose_blocks_with_lists(content):
- """Validate at least 2 prose blocks, one with unordered list and one with ordered list"""
- errors = []
-
- if not isinstance(content, list):
- return errors
-
- # Find all prose blocks
- prose_blocks = [item for item in content if isinstance(item, dict) and item.get('type') == 'prose']
-
- if len(prose_blocks) < 2:
- errors.append(f"Need at least 2 prose blocks (found: {len(prose_blocks)})")
- return errors
-
- # Check for unordered list in at least one prose block
- has_unordered_in_prose = False
- for prose in prose_blocks:
- prose_content = prose.get('content', [])
- if find_content_items(prose_content, 'unorderedList'):
- has_unordered_in_prose = True
- break
-
- if not has_unordered_in_prose:
- errors.append("At least one prose block must contain an unordered list")
-
- # Check for ordered list in at least one prose block
- has_ordered_in_prose = False
- for prose in prose_blocks:
- prose_content = prose.get('content', [])
- if find_content_items(prose_content, 'orderedList'):
- has_ordered_in_prose = True
- break
-
- if not has_ordered_in_prose:
- errors.append("At least one prose block must contain an ordered list")
-
- return errors
-
-def validate_nested_lists(content):
- """Validate that lists have nested sub-lists"""
- errors = []
-
- # Check unordered lists
- unordered_lists = find_content_items(content, 'unorderedList')
- has_nested_unordered = False
- for ul in unordered_lists:
- for item in ul.get('content', []):
- if isinstance(item, dict) and item.get('type') == 'listItem':
- # Check if this list item contains a nested list
- item_content = item.get('content', [])
- if find_content_items(item_content, 'unorderedList'):
- has_nested_unordered = True
- break
- if has_nested_unordered:
- break
-
- if not has_nested_unordered:
- errors.append("Unordered list must have nested sub-lists")
-
- # Check ordered lists
- ordered_lists = find_content_items(content, 'orderedList')
- has_nested_ordered = False
- for ol in ordered_lists:
- for item in ol.get('content', []):
- if isinstance(item, dict) and item.get('type') == 'listItem':
- # Check if this list item contains a nested list
- item_content = item.get('content', [])
- if find_content_items(item_content, 'orderedList'):
- has_nested_ordered = True
- break
- if has_nested_ordered:
- break
-
- if not has_nested_ordered:
- errors.append("Ordered list must have nested sub-lists")
-
- return errors
-
-def validate_table_structure_and_caption(content):
- """Validate that tables have headers, cells, and proper captions"""
- errors = []
- tables = find_content_items(content, 'table')
-
- if not tables:
- errors.append("Missing table (at least 1 required)")
- return errors
-
- for table_idx, table in enumerate(tables, 1):
- # Validate caption
- caption = table.get('attrs', {}).get('caption', '')
-
- if not caption or caption.strip() == '':
- errors.append(f"Table {table_idx}: Caption cannot be empty")
- elif caption == "Table caption":
- errors.append(f"Table {table_idx}: Caption cannot be the default text 'Table caption'")
-
- # Validate table structure (has headers and cells)
- has_header = False
- has_cell = False
-
- table_content = table.get('content', [])
- for row in table_content:
- if isinstance(row, dict) and row.get('type') == 'tableRow':
- row_content = row.get('content', [])
- for cell in row_content:
- if isinstance(cell, dict):
- if cell.get('type') == 'tableHeader':
- has_header = True
- elif cell.get('type') == 'tableCell':
- has_cell = True
-
- if not has_header:
- errors.append(f"Table {table_idx}: Missing table headers (tableHeader)")
- if not has_cell:
- errors.append(f"Table {table_idx}: Missing table cells (tableCell)")
-
- return errors
-
-def validate_accordions(content):
- """Validate that exactly 2 accordions are present with non-empty summaries"""
- errors = []
-
- # Count accordions at the top level of content array
- accordion_count = 0
- accordions = []
- if isinstance(content, list):
- for item in content:
- if isinstance(item, dict) and item.get('type') == 'accordion':
- accordion_count += 1
- accordions.append(item)
-
- if accordion_count == 0:
- errors.append("Missing accordions (required: 2)")
- elif accordion_count == 1:
- errors.append("Only 1 accordion found (required: 2)")
- elif accordion_count > 2:
- errors.append(f"Too many accordions found: {accordion_count} (required: 2)")
-
- # Validate accordion summaries
- for idx, accordion in enumerate(accordions, 1):
- summary = accordion.get('summary', '')
- if not summary or summary.strip() == '':
- errors.append(f"Accordion {idx}: Summary cannot be empty")
-
- return errors
-
-def validate_images(content):
- """Validate that at least 1 image is present with proper alt text and non-placeholder src"""
- errors = []
-
- # Find all images (both standalone and within other components)
- all_images = find_content_items(content, 'image')
-
- if not all_images:
- errors.append("Missing image (at least 1 required)")
- return errors
-
- for idx, image in enumerate(all_images, 1):
- # Validate alt text
- alt = image.get('alt', '')
-
- if not alt or alt.strip() == '':
- errors.append(f"Image {idx}: Alt text cannot be empty")
- elif alt == "Add your alt text here":
- errors.append(f"Image {idx}: Alt text cannot be the default text 'Add your alt text here'")
-
- # Validate src
- src = image.get('src', '')
-
- if src == "/placeholder_no_image.png":
- errors.append(f"Image {idx}: Image source cannot be the placeholder '/placeholder_no_image.png'")
-
- return errors
-
-def validate_infocards(content):
- """Validate that infocards component is present and each card has proper title, imageAlt, and imageUrl"""
- errors = []
- infocards = find_content_items(content, 'infocards')
-
- # Check if infocards component exists
- if not infocards:
- errors.append("Missing infocards component")
- return errors
-
- for infocard_idx, infocard in enumerate(infocards, 1):
- cards = infocard.get('cards', [])
-
- if not cards:
- errors.append(f"Infocards {infocard_idx}: No cards found")
- continue
-
- for card_idx, card in enumerate(cards, 1):
- # Validate title
- title = card.get('title', '')
- if not title or title.strip() == '':
- errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Title cannot be empty")
-
- # Validate imageAlt
- image_alt = card.get('imageAlt', '')
- if not image_alt or image_alt.strip() == '':
- errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be empty")
- elif image_alt == "A placeholder image.":
- errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be the placeholder text 'A placeholder image.'")
-
- # Validate imageUrl
- image_url = card.get('imageUrl', '')
- if not image_url or image_url.strip() == '':
- errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image URL cannot be empty")
-
- return errors
-
-def validate_content_not_empty(content):
- """Validate that content array is not empty"""
- errors = []
-
- if not content or len(content) == 0:
- errors.append("Content array cannot be empty")
-
- return errors
-
-def validate_page_json(json_data, filename):
- """Validate page JSON with all requirements"""
- errors = []
- page_title = "N/A"
-
- try:
- # Extract page title
- page_title = json_data['page'].get('title', 'N/A')
-
- # Validate summary
- summary = json_data['page']['contentPageHeader']['summary']
-
- if summary.strip() == '':
- errors.append("Summary cannot be empty or contain only whitespace")
- elif summary == "This is the page summary":
- errors.append("Summary cannot be the default text 'This is the page summary'")
-
- # Get content for validation
- content = json_data.get('content', [])
-
- # Validate content is not empty
- content_empty_errors = validate_content_not_empty(content)
- errors.extend(content_empty_errors)
-
- # Only continue validation if content is not empty
- if not content_empty_errors:
- # Validate heading hierarchy across entire document
- heading_errors = validate_heading_hierarchy(content)
- errors.extend(heading_errors)
-
- # Validate prose blocks with lists
- prose_errors = validate_prose_blocks_with_lists(content)
- errors.extend(prose_errors)
-
- # Validate nested lists
- nested_list_errors = validate_nested_lists(content)
- errors.extend(nested_list_errors)
-
- # Validate tables (at least 1, validate all if multiple)
- table_errors = validate_table_structure_and_caption(content)
- errors.extend(table_errors)
-
- # Validate accordions (exactly 2)
- accordion_errors = validate_accordions(content)
- errors.extend(accordion_errors)
-
- # Validate images (at least 1, validate all if multiple)
- image_errors = validate_images(content)
- errors.extend(image_errors)
-
- # Validate infocards
- infocard_errors = validate_infocards(content)
- errors.extend(infocard_errors)
-
- except KeyError as e:
- errors.append(f"Missing required field: {str(e)}")
-
- return {
- 'valid': len(errors) == 0,
- 'errors': errors,
- 'title': page_title
- }
-
-def validate_json_file(file_path):
- """Validate a single JSON file"""
- try:
- with open(file_path, 'r', encoding='utf-8') as f:
- json_data = json.load(f)
-
- filename = os.path.basename(file_path)
- return validate_page_json(json_data, filename)
-
- except json.JSONDecodeError as e:
- return {
- 'valid': False,
- 'errors': [f"Invalid JSON format: {str(e)}"],
- 'title': 'N/A'
- }
- except Exception as e:
- return {
- 'valid': False,
- 'errors': [f"Error reading file: {str(e)}"],
- 'title': 'N/A'
- }
-
-def generate_csv_report(results, directory):
- """Generate CSV report in reports folder with errors on separate rows"""
- # Create reports folder if it doesn't exist
- reports_dir = Path('reports')
- reports_dir.mkdir(exist_ok=True)
-
- # Create report filename based on folder name
- folder_name = os.path.basename(os.path.abspath(directory))
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
- report_filename = reports_dir / f"{folder_name}_report_{timestamp}.csv"
-
- # Write CSV report
- with open(report_filename, 'w', newline='', encoding='utf-8') as csvfile:
- fieldnames = ['Name', 'Status', 'Errors']
- writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
-
- writer.writeheader()
- for file_result in results['files']:
- if file_result['errors']:
- # Write first error with name and status
- writer.writerow({
- 'Name': file_result['title'],
- 'Status': 'PASS' if file_result['valid'] else 'FAIL',
- 'Errors': file_result['errors'][0]
- })
- # Write remaining errors with empty name and status
- for error in file_result['errors'][1:]:
- writer.writerow({
- 'Name': '',
- 'Status': '',
- 'Errors': error
- })
- else:
- # No errors, just write the single row
- writer.writerow({
- 'Name': file_result['title'],
- 'Status': 'PASS',
- 'Errors': ''
- })
-
- print(f"\n๐ CSV report generated: {report_filename}")
- return str(report_filename)
-
-def validate_multiple_files(directory):
- """Validate all JSON files in a directory"""
- results = {
- 'total': 0,
- 'passed': 0,
- 'failed': 0,
- 'files': []
- }
-
- try:
- directory_path = Path(directory)
- json_files = list(directory_path.glob('*.json'))
-
- if not json_files:
- print(f"No JSON files found in {directory}")
- return results
-
- print(f"Found {len(json_files)} JSON file(s) to validate\n")
- print("=" * 60)
-
- for file_path in json_files:
- filename = file_path.name
- result = validate_json_file(file_path)
-
- results['total'] += 1
-
- if result['valid']:
- results['passed'] += 1
- print(f"โ
PASS: {filename} (Title: {result['title']})")
- else:
- results['failed'] += 1
- print(f"โ FAIL: {filename} (Title: {result['title']})")
- for error in result['errors']:
- print(f" - {error}")
-
- results['files'].append({
- 'filename': filename,
- 'title': result['title'],
- 'path': str(file_path),
- 'valid': result['valid'],
- 'errors': result['errors']
- })
-
- # Summary
- print("\n" + "=" * 60)
- print("SUMMARY:")
- print(f" Total files: {results['total']}")
- print(f" โ
Passed: {results['passed']}")
- print(f" โ Failed: {results['failed']}")
- print("=" * 60)
-
- # Generate CSV report
- if results['total'] > 0:
- generate_csv_report(results, directory)
-
- return results
-
- except Exception as e:
- print(f"Error reading directory: {str(e)}")
- return results
-
-def main():
- """Main execution"""
- directory = sys.argv[1] if len(sys.argv) > 1 else '.'
-
- print(f"Validating JSON files in: {os.path.abspath(directory)}\n")
-
- results = validate_multiple_files(directory)
-
- # Exit with error code if any files failed
- sys.exit(1 if results['failed'] > 0 else 0)
-
-if __name__ == '__main__':
- main()
\ No newline at end of file
From db95594b9d4e731ecf6d8ed592441dd7e287f79a Mon Sep 17 00:00:00 2001
From: tingshian9 <105689900+tingshian9@users.noreply.github.com>
Date: Thu, 12 Feb 2026 15:16:32 +0800
Subject: [PATCH 10/10] Add files via upload
---
json-assignment-validator/marking.py | 476 +++++++++++++++++++++++++++
1 file changed, 476 insertions(+)
create mode 100644 json-assignment-validator/marking.py
diff --git a/json-assignment-validator/marking.py b/json-assignment-validator/marking.py
new file mode 100644
index 00000000..38de59ac
--- /dev/null
+++ b/json-assignment-validator/marking.py
@@ -0,0 +1,476 @@
+# marking.py
+import json
+import os
+import sys
+import csv
+from pathlib import Path
+from datetime import datetime
+
+def find_content_items(content, item_type):
+ """Recursively find all items of a specific type in content"""
+ items = []
+
+ if isinstance(content, dict):
+ if content.get('type') == item_type:
+ items.append(content)
+ for value in content.values():
+ items.extend(find_content_items(value, item_type))
+ elif isinstance(content, list):
+ for item in content:
+ items.extend(find_content_items(item, item_type))
+
+ return items
+
+def validate_heading_hierarchy(content):
+ """Validate that headings start at level 2 and follow hierarchy across entire document"""
+ errors = []
+ headings = find_content_items(content, 'heading')
+
+ if not headings:
+ return [] # No headings found, skip validation
+
+ # Check if first heading is level 2
+ if headings[0].get('attrs', {}).get('level') != 2:
+ errors.append("First heading must be level 2")
+
+ # Check hierarchy - each heading should not skip levels
+ for i in range(len(headings) - 1):
+ current_level = headings[i].get('attrs', {}).get('level', 0)
+ next_level = headings[i + 1].get('attrs', {}).get('level', 0)
+
+ # Next heading can be same level, one level deeper, or any level shallower
+ if next_level > current_level + 1:
+ errors.append(f"Heading hierarchy error: jumped from level {current_level} to level {next_level}")
+
+ return errors
+
+def validate_prose_blocks_with_lists(content):
+ """Validate at least 2 prose blocks, one with unordered list and one with ordered list"""
+ errors = []
+
+ if not isinstance(content, list):
+ return errors
+
+ # Find all prose blocks
+ prose_blocks = [item for item in content if isinstance(item, dict) and item.get('type') == 'prose']
+
+ if len(prose_blocks) < 2:
+ errors.append(f"Need at least 2 prose blocks (found: {len(prose_blocks)})")
+ return errors
+
+ # Check for unordered list in at least one prose block
+ has_unordered_in_prose = False
+ for prose in prose_blocks:
+ prose_content = prose.get('content', [])
+ if find_content_items(prose_content, 'unorderedList'):
+ has_unordered_in_prose = True
+ break
+
+ if not has_unordered_in_prose:
+ errors.append("At least one prose block must contain an unordered list")
+
+ # Check for ordered list in at least one prose block
+ has_ordered_in_prose = False
+ for prose in prose_blocks:
+ prose_content = prose.get('content', [])
+ if find_content_items(prose_content, 'orderedList'):
+ has_ordered_in_prose = True
+ break
+
+ if not has_ordered_in_prose:
+ errors.append("At least one prose block must contain an ordered list")
+
+ return errors
+
+def validate_nested_lists(content):
+ """Validate that lists have nested sub-lists"""
+ errors = []
+
+ # Check unordered lists
+ unordered_lists = find_content_items(content, 'unorderedList')
+ has_nested_unordered = False
+ for ul in unordered_lists:
+ for item in ul.get('content', []):
+ if isinstance(item, dict) and item.get('type') == 'listItem':
+ # Check if this list item contains a nested list
+ item_content = item.get('content', [])
+ if find_content_items(item_content, 'unorderedList'):
+ has_nested_unordered = True
+ break
+ if has_nested_unordered:
+ break
+
+ if not has_nested_unordered:
+ errors.append("Unordered list must have nested sub-lists")
+
+ # Check ordered lists
+ ordered_lists = find_content_items(content, 'orderedList')
+ has_nested_ordered = False
+ for ol in ordered_lists:
+ for item in ol.get('content', []):
+ if isinstance(item, dict) and item.get('type') == 'listItem':
+ # Check if this list item contains a nested list
+ item_content = item.get('content', [])
+ if find_content_items(item_content, 'orderedList'):
+ has_nested_ordered = True
+ break
+ if has_nested_ordered:
+ break
+
+ if not has_nested_ordered:
+ errors.append("Ordered list must have nested sub-lists")
+
+ return errors
+
+def validate_table_structure_and_caption(content):
+ """Validate that tables have headers, cells, and proper captions"""
+ errors = []
+ tables = find_content_items(content, 'table')
+
+ if not tables:
+ errors.append("Missing table (at least 1 required)")
+ return errors
+
+ for table_idx, table in enumerate(tables, 1):
+ # Validate caption
+ caption = table.get('attrs', {}).get('caption', '')
+
+ if not caption or caption.strip() == '':
+ errors.append(f"Table {table_idx}: Caption cannot be empty")
+ elif caption == "Table caption":
+ errors.append(f"Table {table_idx}: Caption cannot be the default text 'Table caption'")
+
+ # Validate table structure (has headers and cells)
+ has_header = False
+ has_cell = False
+
+ table_content = table.get('content', [])
+ for row in table_content:
+ if isinstance(row, dict) and row.get('type') == 'tableRow':
+ row_content = row.get('content', [])
+ for cell in row_content:
+ if isinstance(cell, dict):
+ if cell.get('type') == 'tableHeader':
+ has_header = True
+ elif cell.get('type') == 'tableCell':
+ has_cell = True
+
+ if not has_header:
+ errors.append(f"Table {table_idx}: Missing table headers (tableHeader)")
+ if not has_cell:
+ errors.append(f"Table {table_idx}: Missing table cells (tableCell)")
+
+ return errors
+
+def validate_accordions(content):
+ """Validate that exactly 2 accordions are present with non-empty summaries"""
+ errors = []
+
+ # Count accordions at the top level of content array
+ accordion_count = 0
+ accordions = []
+ if isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get('type') == 'accordion':
+ accordion_count += 1
+ accordions.append(item)
+
+ if accordion_count == 0:
+ errors.append("Missing accordions (required: 2)")
+ elif accordion_count == 1:
+ errors.append("Only 1 accordion found (required: 2)")
+ elif accordion_count > 2:
+ errors.append(f"Too many accordions found: {accordion_count} (required: 2)")
+
+ # Validate accordion summaries
+ for idx, accordion in enumerate(accordions, 1):
+ summary = accordion.get('summary', '')
+ if not summary or summary.strip() == '':
+ errors.append(f"Accordion {idx}: Summary cannot be empty")
+
+ return errors
+
+def validate_images(content):
+ """Validate that at least 1 image is present with proper alt text and non-placeholder src"""
+ errors = []
+
+ # Find all images (both standalone and within other components)
+ all_images = find_content_items(content, 'image')
+
+ if not all_images:
+ errors.append("Missing image (at least 1 required)")
+ return errors
+
+ for idx, image in enumerate(all_images, 1):
+ # Validate alt text
+ alt = image.get('alt', '')
+
+ if not alt or alt.strip() == '':
+ errors.append(f"Image {idx}: Alt text cannot be empty")
+ elif alt == "Add your alt text here":
+ errors.append(f"Image {idx}: Alt text cannot be the default text 'Add your alt text here'")
+
+ # Validate src
+ src = image.get('src', '')
+
+ if src == "/placeholder_no_image.png":
+ errors.append(f"Image {idx}: Image source cannot be the placeholder '/placeholder_no_image.png'")
+
+ return errors
+
+def validate_infocards(content):
+ """Validate that infocards component is present and each card has proper title, imageAlt, and imageUrl"""
+ errors = []
+ infocards = find_content_items(content, 'infocards')
+
+ # Check if infocards component exists
+ if not infocards:
+ errors.append("Missing infocards component")
+ return errors
+
+ for infocard_idx, infocard in enumerate(infocards, 1):
+ cards = infocard.get('cards', [])
+
+ if not cards:
+ errors.append(f"Infocards {infocard_idx}: No cards found")
+ continue
+
+ for card_idx, card in enumerate(cards, 1):
+ # Validate title
+ title = card.get('title', '')
+ if not title or title.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Title cannot be empty")
+
+ # Validate imageAlt
+ image_alt = card.get('imageAlt', '')
+ if not image_alt or image_alt.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be empty")
+ elif image_alt == "A placeholder image.":
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image alt text cannot be the placeholder text 'A placeholder image.'")
+
+ # Validate imageUrl
+ image_url = card.get('imageUrl', '')
+ if not image_url or image_url.strip() == '':
+ errors.append(f"Infocards {infocard_idx}, Card {card_idx}: Image URL cannot be empty")
+
+ return errors
+
+def validate_content_not_empty(content):
+ """Validate that content array is not empty"""
+ errors = []
+
+ if not content or len(content) == 0:
+ errors.append("Content array cannot be empty")
+
+ return errors
+
+def validate_page_json(json_data, filename):
+ """Validate page JSON with all requirements"""
+ errors = []
+ page_title = "N/A"
+
+ try:
+ # Extract page title
+ page_title = json_data['page'].get('title', 'N/A')
+
+ # Validate summary
+ summary = json_data['page']['contentPageHeader']['summary']
+
+ if summary.strip() == '':
+ errors.append("Summary cannot be empty or contain only whitespace")
+ elif summary == "This is the page summary":
+ errors.append("Summary cannot be the default text 'This is the page summary'")
+
+ # Get content for validation
+ content = json_data.get('content', [])
+
+ # Validate content is not empty
+ content_empty_errors = validate_content_not_empty(content)
+ errors.extend(content_empty_errors)
+
+ # Only continue validation if content is not empty
+ if not content_empty_errors:
+ # Validate heading hierarchy across entire document
+ heading_errors = validate_heading_hierarchy(content)
+ errors.extend(heading_errors)
+
+ # Validate prose blocks with lists
+ prose_errors = validate_prose_blocks_with_lists(content)
+ errors.extend(prose_errors)
+
+ # Validate nested lists
+ nested_list_errors = validate_nested_lists(content)
+ errors.extend(nested_list_errors)
+
+ # Validate tables (at least 1, validate all if multiple)
+ table_errors = validate_table_structure_and_caption(content)
+ errors.extend(table_errors)
+
+ # Validate accordions (exactly 2)
+ accordion_errors = validate_accordions(content)
+ errors.extend(accordion_errors)
+
+ # Validate images (at least 1, validate all if multiple)
+ image_errors = validate_images(content)
+ errors.extend(image_errors)
+
+ # Validate infocards
+ infocard_errors = validate_infocards(content)
+ errors.extend(infocard_errors)
+
+ except KeyError as e:
+ errors.append(f"Missing required field: {str(e)}")
+
+ return {
+ 'valid': len(errors) == 0,
+ 'errors': errors,
+ 'title': page_title
+ }
+
+def validate_json_file(file_path):
+ """Validate a single JSON file"""
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ json_data = json.load(f)
+
+ filename = os.path.basename(file_path)
+ return validate_page_json(json_data, filename)
+
+ except json.JSONDecodeError as e:
+ return {
+ 'valid': False,
+ 'errors': [f"Invalid JSON format: {str(e)}"],
+ 'title': 'N/A'
+ }
+ except Exception as e:
+ return {
+ 'valid': False,
+ 'errors': [f"Error reading file: {str(e)}"],
+ 'title': 'N/A'
+ }
+
+def generate_csv_report(results, directory):
+ """Generate CSV report inside project folder (json-assignment-validator/reports)"""
+
+ # ๐น Get the directory where marking.py is located
+ base_dir = Path(__file__).resolve().parent
+
+ # ๐น Create reports folder inside project directory
+ reports_dir = base_dir / 'reports'
+ reports_dir.mkdir(exist_ok=True)
+
+ folder_name = os.path.basename(os.path.abspath(directory))
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ report_filename = reports_dir / f"{folder_name}_report_{timestamp}.csv"
+
+ # ๐น Sort files: PASS first, FAIL after
+ sorted_files = sorted(
+ results['files'],
+ key=lambda x: (not x['valid'], x['title']) # PASS first, then alphabetical by title
+ )
+
+ with open(report_filename, 'w', newline='', encoding='utf-8') as csvfile:
+ fieldnames = ['Name', 'Status', 'Errors']
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+
+ writer.writeheader()
+
+ for file_result in sorted_files:
+ if file_result['errors']:
+ writer.writerow({
+ 'Name': file_result['title'],
+ 'Status': 'FAIL',
+ 'Errors': file_result['errors'][0]
+ })
+
+ for error in file_result['errors'][1:]:
+ writer.writerow({
+ 'Name': '',
+ 'Status': '',
+ 'Errors': error
+ })
+ else:
+ writer.writerow({
+ 'Name': file_result['title'],
+ 'Status': 'PASS',
+ 'Errors': ''
+ })
+
+ return str(report_filename)
+
+def validate_multiple_files(directory):
+ """Validate all JSON files in a directory (summary-only output)"""
+ results = {
+ 'total': 0,
+ 'passed': 0,
+ 'failed': 0,
+ 'files': []
+ }
+
+ try:
+ directory_path = Path(directory)
+ json_files = list(directory_path.glob('*.json'))
+
+ if not json_files:
+ print(f"No JSON files found in {directory}")
+ return results
+
+ for file_path in json_files:
+ result = validate_json_file(file_path)
+
+ results['total'] += 1
+
+ if result['valid']:
+ results['passed'] += 1
+ else:
+ results['failed'] += 1
+
+ results['files'].append({
+ 'filename': file_path.name,
+ 'title': result['title'],
+ 'path': str(file_path),
+ 'valid': result['valid'],
+ 'errors': result['errors']
+ })
+
+ # Clean summary only
+ # Clean summary only with percentages
+ print("\nValidation Complete")
+ print("=" * 40)
+
+ total = results['total']
+ passed = results['passed']
+ failed = results['failed']
+
+ pass_percentage = (passed / total * 100) if total > 0 else 0
+ fail_percentage = (failed / total * 100) if total > 0 else 0
+
+ print(f"Total files checked: {total}")
+ print(f"Passed: {passed} ({pass_percentage:.1f}%)")
+ print(f"Failed: {failed} ({fail_percentage:.1f}%)")
+
+ print("=" * 40)
+
+ # Generate CSV report
+ if results['total'] > 0:
+ generate_csv_report(results, directory)
+
+ return results
+
+ except Exception as e:
+ print(f"Error reading directory: {str(e)}")
+ return results
+
+def main():
+ """Main execution"""
+ directory = sys.argv[1] if len(sys.argv) > 1 else '.'
+
+ print(f"Validating JSON files in: {os.path.abspath(directory)}\n")
+
+ results = validate_multiple_files(directory)
+
+ # Exit with error code if any files failed
+ sys.exit(1 if results['failed'] > 0 else 0)
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file