diff --git a/README.md b/README.md index 054f7a4..1a95493 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ reproducing Microsoft Learn pages. ## Project status -The project is in its first foundation milestone. The repository currently -provides separate public reader and private administration application -factories, environment-validated settings, automated boundary tests, and CI. -Reader features and DP-700 content will be added through the public roadmap. +The project is in its first reader milestone. The repository provides separate +public reader and private administration application factories, a validated +exam-agnostic book contract, the current DP-700 outline, sanitized Markdown +rendering, a responsive reader shell, automated boundary tests, and CI. Reader +preferences, local progress, annotations, and offline support remain roadmap +work. ## Architecture boundary @@ -62,6 +64,10 @@ project is not affiliated with or endorsed by Microsoft. Microsoft owns its learning material; this project links to authoritative sources and publishes original study-oriented writing under the repository license. +The DP-700 manifest records the official skills blueprint effective July 21, +2026. See [`docs/content-model.md`](docs/content-model.md) for stable identifier, +ordering, and source-mapping rules. + ## Contributing and security Work is tracked through GitHub issues and reviewed pull requests. Read diff --git a/content/published/dp700/book.yaml b/content/published/dp700/book.yaml new file mode 100644 index 0000000..578e085 --- /dev/null +++ b/content/published/dp700/book.yaml @@ -0,0 +1,137 @@ +id: dp700 +exam_code: DP-700 +title: Implementing Data Engineering Solutions Using Microsoft Fabric +blueprint_effective_date: 2026-07-21 +blueprint_url: https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700 +sources: + - id: dp700-study-guide + title: Official DP-700 study guide + url: https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700 +domains: + - id: implement-manage + title: Implement and manage an analytics solution + weight: "30–35%" + chapters: + - id: workspace-settings + slug: workspace-settings + title: Configure Microsoft Fabric workspace settings + content_path: workspace-settings.md + status: draft + source_ids: [dp700-study-guide] + objectives: + - {id: implement.workspace.spark, title: Configure Spark workspace settings} + - {id: implement.workspace.domain, title: Configure domain workspace settings} + - {id: implement.workspace.onelake, title: Configure OneLake workspace settings} + - {id: implement.workspace.airflow, title: Configure Apache Airflow workspace settings} + - id: lifecycle-management + slug: lifecycle-management + title: Implement lifecycle management in Fabric + content_path: lifecycle-management.md + source_ids: [dp700-study-guide] + objectives: + - {id: implement.lifecycle.version-control, title: Configure version control} + - {id: implement.lifecycle.database-projects, title: Implement database projects} + - {id: implement.lifecycle.deployment-pipelines, title: Create and configure deployment pipelines} + - id: security-governance + slug: security-governance + title: Configure security and governance + content_path: security-governance.md + source_ids: [dp700-study-guide] + objectives: + - {id: implement.security.workspace-access, title: Implement workspace-level access controls} + - {id: implement.security.item-access, title: Implement item-level access controls} + - {id: implement.security.data-access, title: "Implement row-level, column-level, object-level, and folder/file-level access controls"} + - {id: implement.security.masking, title: Implement dynamic data masking} + - {id: implement.security.sensitivity, title: Apply sensitivity labels to items} + - {id: implement.security.endorsement, title: Endorse items} + - {id: implement.security.audit, title: Implement and use Microsoft Fabric audit logs} + - {id: implement.security.onelake, title: Configure and implement OneLake security} + - id: orchestration + slug: orchestration + title: Orchestrate processes + content_path: orchestration.md + source_ids: [dp700-study-guide] + objectives: + - {id: implement.orchestration.choose-tool, title: "Choose between Dataflow Gen2, a pipeline, and a notebook"} + - {id: implement.orchestration.triggers, title: Design and implement schedules and event-based triggers} + - {id: implement.orchestration.patterns, title: "Implement orchestration patterns with notebooks and pipelines, including parameters and dynamic expressions"} + - id: ingest-transform + title: Ingest and transform data + weight: "30–35%" + chapters: + - id: loading-patterns + slug: loading-patterns + title: Design and implement loading patterns + content_path: loading-patterns.md + source_ids: [dp700-study-guide] + objectives: + - {id: ingest.loading.full-incremental, title: Design and implement full and incremental data loads} + - {id: ingest.loading.dimensional, title: Prepare data for loading into a dimensional model} + - {id: ingest.loading.streaming, title: Design and implement a loading pattern for streaming data} + - id: batch-data + slug: batch-data + title: Ingest and transform batch data + content_path: batch-data.md + source_ids: [dp700-study-guide] + objectives: + - {id: ingest.batch.store, title: Choose an appropriate data store} + - {id: ingest.batch.transform-tool, title: "Choose between Dataflows Gen2, notebooks, KQL, and T-SQL for data transformation"} + - {id: ingest.batch.shortcuts, title: Create and manage OneLake shortcuts} + - {id: ingest.batch.mirroring, title: Implement mirroring} + - {id: ingest.batch.pipelines, title: Ingest data by using pipelines} + - {id: ingest.batch.languages, title: "Transform data by using PySpark, SQL, and KQL"} + - {id: ingest.batch.denormalize, title: Denormalize data} + - {id: ingest.batch.aggregate, title: Group and aggregate data} + - {id: ingest.batch.data-quality, title: "Handle duplicate, missing, and late-arriving data"} + - id: streaming-data + slug: streaming-data + title: Ingest and transform streaming data + content_path: streaming-data.md + source_ids: [dp700-study-guide] + objectives: + - {id: ingest.streaming.engine, title: Choose an appropriate streaming engine} + - {id: ingest.streaming.native-shortcut, title: Choose between native tables and OneLake shortcuts in Real-Time Intelligence} + - {id: ingest.streaming.acceleration, title: Choose between Query acceleration for OneLake shortcuts and standard OneLake shortcuts in Real-Time Intelligence} + - {id: ingest.streaming.eventstreams, title: Process data by using Eventstreams} + - {id: ingest.streaming.spark, title: Process data by using Spark structured streaming} + - {id: ingest.streaming.kql, title: Process data by using KQL} + - {id: ingest.streaming.windows, title: Create windowing functions} + - id: monitor-optimize + title: Monitor and optimize an analytics solution + weight: "30–35%" + chapters: + - id: monitor-items + slug: monitor-items + title: Monitor Fabric items + content_path: monitor-items.md + source_ids: [dp700-study-guide] + objectives: + - {id: monitor.items.ingestion, title: Monitor data ingestion} + - {id: monitor.items.transformation, title: Monitor data transformation} + - {id: monitor.items.semantic-refresh, title: Monitor semantic model refresh} + - {id: monitor.items.alerts, title: Configure alerts} + - id: resolve-errors + slug: resolve-errors + title: Identify and resolve errors + content_path: resolve-errors.md + source_ids: [dp700-study-guide] + objectives: + - {id: monitor.errors.pipeline, title: Identify and resolve pipeline errors} + - {id: monitor.errors.dataflow, title: Identify and resolve Dataflow Gen2 errors} + - {id: monitor.errors.notebook, title: Identify and resolve notebook errors} + - {id: monitor.errors.eventhouse, title: Identify and resolve Eventhouse errors} + - {id: monitor.errors.eventstream, title: Identify and resolve Eventstream errors} + - {id: monitor.errors.tsql, title: Identify and resolve T-SQL errors} + - {id: monitor.errors.shortcut, title: Identify and resolve OneLake shortcut errors} + - id: optimize-performance + slug: optimize-performance + title: Optimize performance + content_path: optimize-performance.md + source_ids: [dp700-study-guide] + objectives: + - {id: monitor.optimize.lakehouse, title: Optimize a Lakehouse table} + - {id: monitor.optimize.pipeline, title: Optimize a pipeline} + - {id: monitor.optimize.warehouse, title: Optimize a data warehouse} + - {id: monitor.optimize.realtime, title: Optimize Eventstreams and Eventhouses} + - {id: monitor.optimize.spark, title: Optimize Spark performance} + - {id: monitor.optimize.query, title: Optimize query performance} diff --git a/content/published/dp700/chapters/batch-data.md b/content/published/dp700/chapters/batch-data.md new file mode 100644 index 0000000..030e2f5 --- /dev/null +++ b/content/published/dp700/chapters/batch-data.md @@ -0,0 +1,14 @@ +# Ingest and transform batch data + + +This chapter will connect store selection, ingestion mechanisms, transformation languages, and data-quality handling into an end-to-end batch design. + +## Planned study work + +- Choose among shortcuts, mirroring, and pipeline copies. +- Compare Dataflows Gen2, notebooks, KQL, and T-SQL. +- Handle duplicate, missing, and late-arriving records explicitly. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/lifecycle-management.md b/content/published/dp700/chapters/lifecycle-management.md new file mode 100644 index 0000000..5c68a5a --- /dev/null +++ b/content/published/dp700/chapters/lifecycle-management.md @@ -0,0 +1,14 @@ +# Implement lifecycle management in Fabric + + +This chapter will connect version control, database projects, and deployment pipelines into one reviewable DEV-to-production lifecycle. + +## Planned study work + +- Compare Git integration with deployment pipelines. +- Trace how database project changes are built and reviewed. +- Practice selecting the correct promotion mechanism for a scenario. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/loading-patterns.md b/content/published/dp700/chapters/loading-patterns.md new file mode 100644 index 0000000..619c67a --- /dev/null +++ b/content/published/dp700/chapters/loading-patterns.md @@ -0,0 +1,14 @@ +# Design and implement loading patterns + + +This chapter will model full, incremental, dimensional, and streaming loads as choices driven by source behavior, latency, correctness, and recovery needs. + +## Planned study work + +- Compare watermarks, change tracking, and full reloads. +- Prepare facts and dimensions for reliable loading. +- Design a recoverable streaming ingestion path. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/monitor-items.md b/content/published/dp700/chapters/monitor-items.md new file mode 100644 index 0000000..2cb56f9 --- /dev/null +++ b/content/published/dp700/chapters/monitor-items.md @@ -0,0 +1,14 @@ +# Monitor Fabric items + + +This chapter will develop an evidence path across ingestion, transformation, semantic model refresh, and actionable alerts. + +## Planned study work + +- Identify the correct monitoring surface for each item. +- Correlate upstream ingestion with downstream refresh behavior. +- Configure alerts around operationally meaningful conditions. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/optimize-performance.md b/content/published/dp700/chapters/optimize-performance.md new file mode 100644 index 0000000..901e144 --- /dev/null +++ b/content/published/dp700/chapters/optimize-performance.md @@ -0,0 +1,14 @@ +# Optimize performance + + +This chapter will separate storage layout, orchestration, compute, and query optimization so that each performance change is tied to measured evidence. + +## Planned study work + +- Diagnose before selecting an optimization. +- Compare Lakehouse, warehouse, Spark, and real-time tuning levers. +- Validate improvements against a repeatable workload. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/orchestration.md b/content/published/dp700/chapters/orchestration.md new file mode 100644 index 0000000..bfd9710 --- /dev/null +++ b/content/published/dp700/chapters/orchestration.md @@ -0,0 +1,14 @@ +# Orchestrate processes + + +This chapter will compare Dataflow Gen2, pipelines, and notebooks, then develop schedules, event triggers, parameters, and dynamic orchestration patterns. + +## Planned study work + +- Select an orchestration tool from workload constraints. +- Trace parameters through a multi-step pipeline. +- Compare scheduled and event-driven execution. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/resolve-errors.md b/content/published/dp700/chapters/resolve-errors.md new file mode 100644 index 0000000..dbcc6fe --- /dev/null +++ b/content/published/dp700/chapters/resolve-errors.md @@ -0,0 +1,14 @@ +# Identify and resolve errors + + +This chapter will use a consistent diagnose-isolate-correct-verify loop across pipelines, Dataflows Gen2, notebooks, Eventhouse, Eventstreams, T-SQL, and shortcuts. + +## Planned study work + +- Map common failures to their best diagnostic evidence. +- Separate configuration, identity, data, and runtime causes. +- Turn recurring failures into regression checks. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/security-governance.md b/content/published/dp700/chapters/security-governance.md new file mode 100644 index 0000000..5cba37f --- /dev/null +++ b/content/published/dp700/chapters/security-governance.md @@ -0,0 +1,14 @@ +# Configure security and governance + + +This chapter will distinguish workspace, item, data, and OneLake security boundaries, then connect them to labels, endorsements, masking, and audit evidence. + +## Planned study work + +- Build a role-and-scope decision table. +- Compare row, column, object, and file-level controls. +- Work through governance scenarios without conflating discovery and authorization. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/streaming-data.md b/content/published/dp700/chapters/streaming-data.md new file mode 100644 index 0000000..4fccc60 --- /dev/null +++ b/content/published/dp700/chapters/streaming-data.md @@ -0,0 +1,14 @@ +# Ingest and transform streaming data + + +This chapter will compare streaming engines, Real-Time Intelligence storage choices, Eventstreams, Spark structured streaming, KQL, and window semantics. + +## Planned study work + +- Select an engine from latency and processing requirements. +- Compare native tables with standard and accelerated shortcuts. +- Reason about tumbling, hopping, and sliding windows. + +## Source + +See the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700). diff --git a/content/published/dp700/chapters/workspace-settings.md b/content/published/dp700/chapters/workspace-settings.md new file mode 100644 index 0000000..127e7fd --- /dev/null +++ b/content/published/dp700/chapters/workspace-settings.md @@ -0,0 +1,42 @@ +# Configure Microsoft Fabric workspace settings + + +Workspace settings are control points. They shape how a team uses compute, organizes ownership, reaches shared data, and schedules orchestration. For the exam, focus on **who controls a setting, what scope it affects, and what operational consequence follows**. + +## Start with the boundary + + +A workspace is a collaboration and security boundary, but not every behavior is controlled at workspace scope. Some defaults inherit from capacity or tenant administration. Before changing a setting, identify the governing scope and whether the workspace administrator can override it. + +| Setting family | Primary concern | Exam question to ask | +| --- | --- | --- | +| Spark | Compute defaults and runtime behavior | Does this change the workspace default or only one session? | +| Domain | Business ownership and discovery | Is the workspace assigned to the correct data domain? | +| OneLake | How data is exposed and accessed | Does the change affect access, discoverability, or data movement? | +| Apache Airflow | Managed orchestration configuration | Who owns connections, schedules, and operational monitoring? | + +## A practical decision sequence + + +1. Identify the required outcome: performance, governance, access, or orchestration. +2. Locate the narrowest administrative scope that owns the behavior. +3. Check inheritance and override rules before changing a default. +4. Validate the effect with a representative workload rather than assuming the setting is isolated. +5. Record the operational owner and a rollback path. + +## Exam distinction + + +Do not treat all workspace settings as interchangeable toggles. Spark settings primarily influence compute behavior; domain assignment communicates business organization; OneLake settings influence the shared data plane; and Apache Airflow settings support managed workflow orchestration. A scenario usually gives clues about **scope**, **owner**, and **effect**. + +## Active recall + + +- Which settings would you examine first when every notebook in a workspace starts with an unsuitable Spark default? +- Why is assigning a workspace to a domain different from granting access to the workspace? +- What evidence would you collect before changing a setting that may affect multiple workloads? + +## Source + + +Use the [official DP-700 study guide](https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/dp-700) as the authoritative skills outline. This chapter is original study-oriented writing and will be expanded with setting-specific Microsoft Learn sources in the content-authoring milestone. diff --git a/docs/content-model.md b/docs/content-model.md new file mode 100644 index 0000000..9ec24f6 --- /dev/null +++ b/docs/content-model.md @@ -0,0 +1,46 @@ +# Book content model + +Study Reader separates authored chapters from application code. A published +book is a directory containing `book.yaml` and a `chapters/` directory of +Markdown files. + +## Stable identifiers + +- Book, domain, chapter, source, and objective identifiers MUST be lowercase, + human-readable, and stable after publication. +- Chapter slugs MUST be unique within a book and MUST remain stable when a title + changes. +- Objective identifiers SHOULD describe their hierarchy, such as + `ingest.streaming.windows`. +- An explicit `` marker assigns a durable anchor + to the next top-level Markdown block. Authors SHOULD use explicit block IDs + for important passages that may receive bookmarks, highlights, or notes. +- Blocks without explicit markers receive deterministic section-and-position + identifiers. Those fallbacks survive ordinary wording edits but MAY change if + blocks are inserted or reordered in the same section. + +## Ordering + +YAML list order is authoritative. Domain order follows the official skills +outline. Chapter order follows the subdomains within each domain. Objective +order follows the bullet order in the effective official blueprint. The reader +MUST NOT infer order from identifiers or filenames. + +## Sources and ownership + +Every chapter MUST map to at least one source declared in its book manifest. +The manifest records the official blueprint URL and effective date. Additional +chapter-specific sources will record retrieval dates and hashes in the refresh +milestone. + +Microsoft Learn remains authoritative for Microsoft exam objectives and product +behavior. Markdown chapters MUST contain original study-oriented writing and +MUST NOT reproduce Microsoft Learn pages wholesale. + +## Validation + +Pydantic rejects duplicate identifiers, duplicate slugs, broken source +references, invalid paths, and unknown manifest fields. Content tests confirm +that every planned chapter file exists and maps at least one objective and one +source. Rendering tests verify sanitized HTML, stable anchors, external-link +behavior, and a reviewed golden fixture. diff --git a/pyproject.toml b/pyproject.toml index c3a3334..24b06bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,10 @@ authors = [{ name = "Troy Scott" }] dependencies = [ "fastapi>=0.116,<1", "jinja2>=3.1,<4", + "markdown-it-py>=4,<5", + "nh3>=0.3,<1", "pydantic-settings>=2.10,<3", + "pyyaml>=6,<7", "uvicorn[standard]>=0.35,<1", ] @@ -24,6 +27,7 @@ dev = [ "pytest>=8.4,<9", "pytest-cov>=6.2,<7", "ruff>=0.12,<1", + "types-pyyaml>=6.0,<7", ] [tool.hatch.build.targets.wheel] diff --git a/src/study_reader/content/__init__.py b/src/study_reader/content/__init__.py new file mode 100644 index 0000000..ba3b913 --- /dev/null +++ b/src/study_reader/content/__init__.py @@ -0,0 +1 @@ +"""Book content contracts and rendering.""" diff --git a/src/study_reader/content/catalog.py b/src/study_reader/content/catalog.py new file mode 100644 index 0000000..ac2c42c --- /dev/null +++ b/src/study_reader/content/catalog.py @@ -0,0 +1,39 @@ +"""Read validated published books from the Git-backed content directory.""" + +import re +from pathlib import Path + +import yaml + +from study_reader.content.models import Book, Chapter + +SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +class BookCatalog: + """A read-only catalog over published book manifests and Markdown.""" + + def __init__(self, root: Path) -> None: + self.root = root + + def load_book(self, book_id: str) -> Book: + """Load and validate one book manifest without permitting path traversal.""" + + if not SAFE_ID.fullmatch(book_id): + raise FileNotFoundError(book_id) + manifest_path = self.root / book_id / "book.yaml" + try: + raw_manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as error: + raise FileNotFoundError(book_id) from error + return Book.model_validate(raw_manifest) + + def chapter_path(self, book: Book, chapter: Chapter) -> Path: + """Return the chapter path constrained to its validated book directory.""" + + return self.root / book.id / "chapters" / chapter.content_path + + def load_chapter_markdown(self, book: Book, chapter: Chapter) -> str: + """Read one authored chapter from the published snapshot.""" + + return self.chapter_path(book, chapter).read_text(encoding="utf-8") diff --git a/src/study_reader/content/markdown.py b/src/study_reader/content/markdown.py new file mode 100644 index 0000000..1d4ad79 --- /dev/null +++ b/src/study_reader/content/markdown.py @@ -0,0 +1,199 @@ +"""Render authored Markdown as sanitized HTML with stable block anchors.""" + +import html +import re +from collections import defaultdict +from collections.abc import Sequence +from urllib.parse import urlparse + +import nh3 +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.token import Token +from markdown_it.utils import EnvType, OptionsDict + +BLOCK_MARKER = re.compile(r"^\s*\s*$") +BLOCK_TYPES = { + "heading_open": "heading", + "paragraph_open": "paragraph", + "bullet_list_open": "list", + "ordered_list_open": "list", + "table_open": "table", + "fence": "code", +} +ALLOWED_TAGS = { + "a", + "blockquote", + "code", + "del", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "li", + "ol", + "p", + "pre", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +} +ALLOWED_ATTRIBUTES = { + "*": {"id"}, + "a": {"href", "target", "rel", "title", "data-external-host"}, + "code": {"class"}, +} + + +def slugify(value: str) -> str: + """Create a stable lowercase identifier suitable for an HTML id.""" + + normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return normalized or "section" + + +def extract_markers(markdown: str) -> tuple[str, list[tuple[int, str]]]: + """Remove explicit block markers while preserving source line positions.""" + + markers: list[tuple[int, str]] = [] + cleaned_lines: list[str] = [] + for line_number, line in enumerate(markdown.splitlines()): + marker = BLOCK_MARKER.fullmatch(line) + if marker: + markers.append((line_number, marker.group(1))) + cleaned_lines.append("") + else: + cleaned_lines.append(line) + return "\n".join(cleaned_lines), markers + + +def inline_text(tokens: Sequence[Token], start_index: int) -> str: + """Return the inline content immediately following a heading token.""" + + for token in tokens[start_index + 1 :]: + if token.type == "inline": + return token.content + if token.type == "heading_close": + break + return "section" + + +def assign_block_ids(tokens: list[Token], markers: list[tuple[int, str]]) -> None: + """Attach deterministic or explicit IDs to rendered block tokens.""" + + marker_index = 0 + pending_marker: str | None = None + current_section = "opening" + counters: defaultdict[tuple[str, str], int] = defaultdict(int) + used_ids: set[str] = set() + + for index, token in enumerate(tokens): + if token.type not in BLOCK_TYPES or token.level != 0: + continue + + start_line = token.map[0] if token.map else -1 + while marker_index < len(markers) and markers[marker_index][0] < start_line: + pending_marker = markers[marker_index][1] + marker_index += 1 + + block_type = BLOCK_TYPES[token.type] + if pending_marker: + block_id = pending_marker + pending_marker = None + elif block_type == "heading": + block_id = slugify(inline_text(tokens, index)) + current_section = block_id + else: + counters[(current_section, block_type)] += 1 + block_id = ( + f"{current_section}-{block_type}-" + f"{counters[(current_section, block_type)]}" + ) + + unique_id = block_id + duplicate_index = 2 + while unique_id in used_ids: + unique_id = f"{block_id}-{duplicate_index}" + duplicate_index += 1 + token.attrSet("id", unique_id) + used_ids.add(unique_id) + + +def render_link_open( + renderer: RendererHTML, + tokens: Sequence[Token], + index: int, + options: OptionsDict, + env: EnvType, +) -> str: + """Make external link behavior explicit, safe, and understandable.""" + + token = tokens[index] + href = str(token.attrGet("href") or "") + destination = urlparse(href) + if destination.scheme in {"http", "https"} and destination.hostname: + hostname = destination.hostname.lower() + token.attrSet("target", "_blank") + token.attrSet("rel", "noopener noreferrer") + token.attrSet("data-external-host", hostname) + token.attrSet("title", f"Opens {hostname} in a new tab") + return renderer.renderToken(tokens, index, options, env) + + +def render_fence( + renderer: RendererHTML, + tokens: Sequence[Token], + index: int, + options: OptionsDict, + env: EnvType, +) -> str: + """Render fenced code with an anchor on the scrollable outer block.""" + + del renderer, options, env + token = tokens[index] + block_id = html.escape(str(token.attrGet("id") or "code")) + language = ( + slugify(token.info.strip().split(maxsplit=1)[0]) if token.info else "text" + ) + code = html.escape(token.content) + return ( + f'
{code}
\n' + ) + + +def markdown_parser() -> MarkdownIt: + """Build the documented CommonMark parser with only required extensions.""" + + parser = MarkdownIt("commonmark", {"html": False, "linkify": False}) + parser.enable("table") + parser.add_render_rule("link_open", render_link_open) + parser.add_render_rule("fence", render_fence) + return parser + + +def render_markdown(markdown: str) -> str: + """Render Markdown and enforce the final HTML security allowlist.""" + + cleaned_markdown, markers = extract_markers(markdown) + parser = markdown_parser() + tokens = parser.parse(cleaned_markdown) + assign_block_ids(tokens, markers) + rendered = parser.renderer.render(tokens, parser.options, {}) + return nh3.clean( + rendered, + tags=ALLOWED_TAGS, + clean_content_tags={"script", "style"}, + attributes=ALLOWED_ATTRIBUTES, + url_schemes={"http", "https", "mailto"}, + link_rel=None, + strip_comments=True, + ) diff --git a/src/study_reader/content/models.py b/src/study_reader/content/models.py new file mode 100644 index 0000000..1c5faf5 --- /dev/null +++ b/src/study_reader/content/models.py @@ -0,0 +1,121 @@ +"""Exam-agnostic, validated book content models.""" + +from datetime import date +from functools import cached_property +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator + + +class ContentModel(BaseModel): + """Strict immutable base for published content contracts.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Source(ContentModel): + """An authoritative source cited by one or more chapters.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$") + title: str = Field(min_length=1) + url: HttpUrl + + +class Objective(ContentModel): + """One measured skill mapped to a study chapter.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9.-]*$") + title: str = Field(min_length=1) + + +class Chapter(ContentModel): + """One reflowable chapter and its exam objective mappings.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$") + slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$") + title: str = Field(min_length=1) + content_path: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*\.md$") + objectives: tuple[Objective, ...] = Field(min_length=1) + source_ids: tuple[str, ...] = Field(min_length=1) + status: str = Field( + default="placeholder", pattern=r"^(placeholder|draft|published)$" + ) + + +class Domain(ContentModel): + """An ordered exam skill domain containing ordered chapters.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$") + title: str = Field(min_length=1) + weight: str = Field(pattern=r"^(?:\d{1,3}[\u2013-]\d{1,3}|\d{1,3})%$") + chapters: tuple[Chapter, ...] = Field(min_length=1) + + +class Book(ContentModel): + """A versioned certification book derived from an official blueprint.""" + + id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$") + exam_code: str = Field(pattern=r"^[A-Z]{2,5}-\d{3}$") + title: str = Field(min_length=1) + blueprint_effective_date: date + blueprint_url: HttpUrl + sources: tuple[Source, ...] = Field(min_length=1) + domains: tuple[Domain, ...] = Field(min_length=1) + + @cached_property + def chapters(self) -> tuple[Chapter, ...]: + """Flatten chapters while preserving manifest order.""" + + return tuple(chapter for domain in self.domains for chapter in domain.chapters) + + @property + def chapter_count(self) -> int: + """Return the total number of ordered chapters.""" + + return len(self.chapters) + + def chapter_by_slug(self, slug: str) -> Chapter: + """Find a chapter by stable slug.""" + + for chapter in self.chapters: + if chapter.slug == slug: + return chapter + raise KeyError(slug) + + def chapter_position(self, chapter: Chapter) -> int: + """Return the chapter's zero-based position.""" + + return self.chapters.index(chapter) + + @model_validator(mode="after") + def validate_references_and_identifiers(self) -> Self: + """Reject ambiguous identifiers and broken source mappings.""" + + domain_ids = [domain.id for domain in self.domains] + if len(domain_ids) != len(set(domain_ids)): + raise ValueError("domain identifiers must be unique") + + chapter_ids = [chapter.id for chapter in self.chapters] + chapter_slugs = [chapter.slug for chapter in self.chapters] + if len(chapter_ids) != len(set(chapter_ids)): + raise ValueError("chapter identifiers must be unique") + if len(chapter_slugs) != len(set(chapter_slugs)): + raise ValueError("chapter slugs must be unique") + + objective_ids = [ + objective.id + for chapter in self.chapters + for objective in chapter.objectives + ] + if len(objective_ids) != len(set(objective_ids)): + raise ValueError("objective identifiers must be unique") + + source_ids = {source.id for source in self.sources} + for chapter in self.chapters: + unknown_sources = set(chapter.source_ids) - source_ids + if unknown_sources: + raise ValueError( + f"chapter {chapter.id} references unknown source: " + f"{sorted(unknown_sources)}" + ) + return self diff --git a/src/study_reader/reader/app.py b/src/study_reader/reader/app.py index 8d18ac5..7d9fac6 100644 --- a/src/study_reader/reader/app.py +++ b/src/study_reader/reader/app.py @@ -1,6 +1,9 @@ """Public reader application factory.""" +from pathlib import Path + from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from study_reader.config import Settings, get_settings from study_reader.lifecycle import build_lifespan @@ -15,5 +18,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: title="Study Reader", lifespan=build_lifespan(resolved_settings), ) + app.mount( + "/static", + StaticFiles(directory=Path(__file__).parent / "static"), + name="static", + ) app.include_router(router) return app diff --git a/src/study_reader/reader/routes.py b/src/study_reader/reader/routes.py index 6cbcd73..79152ef 100644 --- a/src/study_reader/reader/routes.py +++ b/src/study_reader/reader/routes.py @@ -1,24 +1,88 @@ """Routes exposed by the public reader.""" -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from markupsafe import Markup +from starlette.responses import Response + +from study_reader.content.catalog import BookCatalog +from study_reader.content.markdown import render_markdown +from study_reader.content.models import Book, Chapter, Domain router = APIRouter() +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + + +def domain_for_chapter(book: Book, chapter: Chapter) -> Domain: + """Return the domain containing a chapter.""" + + for domain in book.domains: + if chapter in domain.chapters: + return domain + raise KeyError(chapter.id) + + +@router.get("/", include_in_schema=False) +async def home() -> RedirectResponse: + """Open the representative chapter in the first published book.""" + + return RedirectResponse( + url="/books/dp700/chapters/workspace-settings", + status_code=307, + ) + + +@router.get("/books/{book_id}", include_in_schema=False) +async def book_home(request: Request, book_id: str) -> RedirectResponse: + """Open the first chapter in a validated book.""" + + catalog = BookCatalog(request.app.state.settings.published_content_dir) + try: + book = catalog.load_book(book_id) + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail="Book not found") from error + return RedirectResponse( + url=f"/books/{book.id}/chapters/{book.chapters[0].slug}", + status_code=307, + ) + +@router.get("/books/{book_id}/chapters/{chapter_slug}", include_in_schema=False) +async def chapter(request: Request, book_id: str, chapter_slug: str) -> Response: + """Render one sanitized, reflowable chapter with ordered navigation.""" -@router.get("/", response_class=HTMLResponse, include_in_schema=False) -async def home(request: Request) -> HTMLResponse: - """Render the initial read-only project status page.""" + catalog = BookCatalog(request.app.state.settings.published_content_dir) + try: + book = catalog.load_book(book_id) + selected_chapter = book.chapter_by_slug(chapter_slug) + markdown = catalog.load_chapter_markdown(book, selected_chapter) + except (FileNotFoundError, KeyError, OSError) as error: + raise HTTPException(status_code=404, detail="Chapter not found") from error + + position = book.chapter_position(selected_chapter) + previous_chapter = book.chapters[position - 1] if position > 0 else None + next_chapter = ( + book.chapters[position + 1] if position + 1 < book.chapter_count else None + ) + source_lookup = {source.id: source for source in book.sources} + sources = [source_lookup[source_id] for source_id in selected_chapter.source_ids] - title = request.app.title - return HTMLResponse( - "" - "" - "" - f"{title}
" - "

Study Reader

" - "

The mobile-first reading experience is under construction.

" - "
" + return templates.TemplateResponse( + request=request, + name="chapter.html", + context={ + "book": book, + "chapter": selected_chapter, + "domain": domain_for_chapter(book, selected_chapter), + "chapter_html": Markup(render_markdown(markdown)), + "chapter_number": position + 1, + "previous_chapter": previous_chapter, + "next_chapter": next_chapter, + "sources": sources, + }, ) diff --git a/src/study_reader/reader/static/favicon.svg b/src/study_reader/reader/static/favicon.svg new file mode 100644 index 0000000..46da53e --- /dev/null +++ b/src/study_reader/reader/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/study_reader/reader/static/reader.css b/src/study_reader/reader/static/reader.css new file mode 100644 index 0000000..a51bfad --- /dev/null +++ b/src/study_reader/reader/static/reader.css @@ -0,0 +1,141 @@ +:root { + color-scheme: light; + --ink: #24231f; + --muted: #6d6a61; + --paper: #fffdf8; + --canvas: #f3efe5; + --line: #ded8ca; + --accent: #315b52; + --accent-soft: #e1ece6; + --warm: #a35c35; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + overflow-x: hidden; + background: var(--paper); + color: var(--ink); +} + +a { color: var(--accent); } +a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-offset: 3px; } + +.skip-link { + position: fixed; + z-index: 20; + top: .5rem; + left: .5rem; + padding: .7rem 1rem; + transform: translateY(-150%); + background: var(--ink); + color: white; +} +.skip-link:focus { transform: translateY(0); } + +.reading-progress { height: 3px; background: var(--line); } +.reading-progress span { display: block; width: var(--chapter-progress); height: 100%; background: var(--warm); } + +.site-header { + min-height: 4.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: .75rem clamp(1rem, 3vw, 2.5rem); + border-bottom: 1px solid var(--line); + background: rgba(255, 253, 248, .96); +} + +.brand { display: flex; align-items: center; gap: .75rem; color: var(--ink); text-decoration: none; } +.brand-mark { display: grid; place-items: center; width: 2.6rem; aspect-ratio: 1; border-radius: .7rem; background: var(--accent); color: white; font: 700 .85rem Georgia, serif; letter-spacing: .08em; } +.brand strong, .brand small { display: block; } +.brand strong { font-family: Georgia, "Times New Roman", serif; font-size: 1.05rem; } +.brand small, .chapter-count { margin-top: .12rem; color: var(--muted); font-size: .76rem; } + +.reader-layout { display: grid; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); min-height: calc(100vh - 4.7rem); } + +.toc-shell { min-width: 0; border-right: 1px solid var(--line); background: var(--canvas); } +.toc-details { position: sticky; top: 0; max-height: 100vh; overflow-y: auto; padding: 1.5rem 1.25rem 3rem; } +.toc-details > summary { display: none; } +.toc-domain + .toc-domain { margin-top: 1.65rem; } +.toc-domain-heading { display: flex; gap: .65rem; align-items: flex-start; color: var(--ink); } +.toc-domain-heading > span { display: grid; flex: 0 0 1.6rem; place-items: center; aspect-ratio: 1; border-radius: 50%; background: var(--accent); color: white; font-size: .72rem; } +.toc-domain-heading strong, .toc-domain-heading small { display: block; } +.toc-domain-heading strong { font-size: .78rem; line-height: 1.35; } +.toc-domain-heading small { margin-top: .25rem; color: var(--muted); font-size: .7rem; } +.toc-domain ol { margin: .75rem 0 0 2.25rem; padding: 0; list-style: none; } +.toc-domain li + li { margin-top: .2rem; } +.toc-domain a { position: relative; display: block; padding: .55rem .7rem; border-radius: .5rem; color: var(--muted); font-size: .78rem; line-height: 1.35; text-decoration: none; } +.toc-domain a:hover { background: rgba(255,255,255,.7); color: var(--ink); } +.toc-domain a[aria-current="page"] { background: var(--paper); color: var(--accent); box-shadow: inset 3px 0 var(--warm); font-weight: 650; } +.status-dot { display: inline-block; width: .4rem; height: .4rem; margin-left: .3rem; border: 1px solid #9d988d; border-radius: 50%; vertical-align: middle; } + +.reading-pane { width: min(100%, 58rem); min-width: 0; margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) clamp(1.15rem, 6vw, 5rem) 5rem; } +.chapter-header { padding-bottom: 2rem; border-bottom: 1px solid var(--line); } +.eyebrow { margin: 0 0 .65rem; color: var(--warm); font-size: .72rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } +.chapter-header h1 { max-width: 18ch; margin: 0; font: 600 clamp(2.25rem, 6vw, 4.25rem)/1.03 Georgia, "Times New Roman", serif; letter-spacing: -.035em; } +.chapter-deck { margin: 1rem 0 0; color: var(--muted); font: 1.05rem/1.5 Georgia, "Times New Roman", serif; } +.objective-strip { display: flex; flex-wrap: wrap; gap: .55rem 1.25rem; margin-top: 1.5rem; color: var(--muted); font-size: .78rem; } +.objective-strip span::before { content: ""; display: inline-block; width: .38rem; height: .38rem; margin: 0 .45rem .08rem 0; border-radius: 50%; background: var(--accent); } + +.chapter-prose { padding-top: 2rem; font: 1.08rem/1.78 Georgia, "Times New Roman", serif; } +.chapter-prose > h1:first-child { display: none; } +.chapter-prose h2 { margin: 3rem 0 .9rem; font: 600 1.65rem/1.2 Georgia, "Times New Roman", serif; letter-spacing: -.018em; scroll-margin-top: 1rem; } +.chapter-prose h3 { margin-top: 2rem; font-size: 1.2rem; } +.chapter-prose p { margin: 0 0 1.25rem; } +.chapter-prose strong { color: #183c34; } +.chapter-prose ul, .chapter-prose ol { margin: 0 0 1.5rem; padding-left: 1.4rem; } +.chapter-prose li { padding-left: .35rem; } +.chapter-prose li + li { margin-top: .45rem; } +.chapter-prose table { display: block; width: 100%; max-width: 100%; margin: 1.75rem 0 2.25rem; overflow-x: auto; border-collapse: collapse; font: .86rem/1.45 Inter, ui-sans-serif, sans-serif; } +.chapter-prose th, .chapter-prose td { min-width: 9rem; padding: .8rem .9rem; border: 1px solid var(--line); text-align: left; vertical-align: top; } +.chapter-prose th { background: var(--accent-soft); color: #17342e; } +.chapter-prose pre { max-width: 100%; margin: 1.75rem 0; padding: 1rem; overflow-x: auto; border-radius: .6rem; background: #202825; color: #edf4ef; font: .86rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } +.chapter-prose a[data-external-host]::after { content: " ↗"; font-family: Inter, sans-serif; font-size: .72em; } + +.source-card { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem; align-items: center; margin-top: 4rem; padding: 1.5rem; border-radius: .8rem; background: var(--accent-soft); } +.source-card h2 { margin: 0; font: 600 1.35rem Georgia, serif; } +.source-card p:not(.eyebrow) { max-width: 46ch; margin: .4rem 0 0; color: var(--muted); font-size: .88rem; line-height: 1.5; } +.source-card > a { padding: .75rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; font-size: .8rem; font-weight: 700; text-decoration: none; } + +.chapter-navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--line); } +.chapter-navigation a { min-width: 0; padding: 1rem; border: 1px solid var(--line); border-radius: .65rem; color: var(--ink); text-decoration: none; } +.chapter-navigation a:hover { border-color: var(--accent); } +.chapter-navigation small, .chapter-navigation span { display: block; } +.chapter-navigation small { margin-bottom: .25rem; color: var(--warm); font-size: .68rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +.chapter-navigation span { overflow-wrap: anywhere; font: .9rem/1.35 Georgia, serif; } +.chapter-navigation .next { text-align: right; } + +@media (max-width: 767px) { + .site-header { position: sticky; z-index: 10; top: 0; min-height: 4.1rem; padding: .65rem 1rem; } + .brand small { display: none; } + .chapter-count { font-size: .7rem; } + .reader-layout { display: block; } + .toc-shell { border-right: 0; border-bottom: 1px solid var(--line); } + .toc-details { position: static; max-height: none; padding: 0; overflow: visible; } + .toc-details > summary { display: flex; min-height: 3rem; align-items: center; justify-content: space-between; padding: .75rem 1rem; cursor: pointer; color: var(--accent); font-size: .8rem; font-weight: 750; } + .toc-details[open] > summary { border-bottom: 1px solid var(--line); } + .toc-details nav { max-height: 65vh; padding: 1.1rem 1rem 2rem; overflow-y: auto; } + .reading-pane { padding: 2.5rem 1.15rem 4rem; } + .chapter-header h1 { font-size: clamp(2.25rem, 12vw, 3.35rem); } + .chapter-prose { font-size: 1.04rem; line-height: 1.72; } + .chapter-prose h2 { margin-top: 2.5rem; } + .source-card { grid-template-columns: 1fr; } + .source-card > a { text-align: center; } + .chapter-navigation { grid-template-columns: 1fr; } + .chapter-navigation .next { grid-column: 1; text-align: left; } +} + +@media (min-width: 768px) { + .toc-details:not([open]) > nav { display: block; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } +} diff --git a/src/study_reader/reader/static/reader.js b/src/study_reader/reader/static/reader.js new file mode 100644 index 0000000..fc5a773 --- /dev/null +++ b/src/study_reader/reader/static/reader.js @@ -0,0 +1,9 @@ +const tableOfContents = document.querySelector(".toc-details"); +const desktopReader = window.matchMedia("(min-width: 768px)"); + +function synchronizeTableOfContents(event) { + tableOfContents.open = event.matches; +} + +synchronizeTableOfContents(desktopReader); +desktopReader.addEventListener("change", synchronizeTableOfContents); diff --git a/src/study_reader/reader/templates/chapter.html b/src/study_reader/reader/templates/chapter.html new file mode 100644 index 0000000..3abd8be --- /dev/null +++ b/src/study_reader/reader/templates/chapter.html @@ -0,0 +1,107 @@ + + + + + + + {{ chapter.title }} · {{ book.exam_code }} Study Reader + + + + + + + + + + +
+ + +
+
+

Domain {{ book.domains.index(domain) + 1 }} · {{ domain.weight }}

+

{{ chapter.title }}

+

{{ domain.title }}

+
+ {{ chapter.objectives | length }} measured skills + Blueprint {{ book.blueprint_effective_date.strftime('%b %Y') }} +
+
+ +
+ {{ chapter_html }} +
+ +
+
+

Authoritative source

+

Grounded in Microsoft Learn

+

This reader summarizes and teaches. Microsoft Learn remains the source of truth.

+
+ {% for source in sources %} + + Open official DP-700 study guide + + {% endfor %} +
+ + +
+
+ + diff --git a/tests/content/fixtures/representative.html b/tests/content/fixtures/representative.html new file mode 100644 index 0000000..5eadb73 --- /dev/null +++ b/tests/content/fixtures/representative.html @@ -0,0 +1,22 @@ +

Orientation

+

Choose a setting deliberately. Check the official guide before relying on memory.

+ + + + + + + + + + + + + + +
SettingQuestion
OneLakeWho can access the data?
+
SELECT 1;
+
diff --git a/tests/content/fixtures/representative.md b/tests/content/fixtures/representative.md new file mode 100644 index 0000000..886efdb --- /dev/null +++ b/tests/content/fixtures/representative.md @@ -0,0 +1,15 @@ +## Orientation + + +Choose a setting deliberately. Check the [official guide](https://learn.microsoft.com/fabric) before relying on memory. + +- Workspace scope +- Capacity scope + +| Setting | Question | +| --- | --- | +| OneLake | Who can access the data? | + +```sql +SELECT 1; +``` diff --git a/tests/content/test_dp700_outline.py b/tests/content/test_dp700_outline.py new file mode 100644 index 0000000..509282c --- /dev/null +++ b/tests/content/test_dp700_outline.py @@ -0,0 +1,38 @@ +"""Official DP-700 blueprint representation tests.""" + +from datetime import date +from pathlib import Path + +from study_reader.content.catalog import BookCatalog + +CONTENT_ROOT = Path("content/published") + + +def test_dp700_outline_matches_current_official_structure() -> None: + book = BookCatalog(CONTENT_ROOT).load_book("dp700") + + assert book.exam_code == "DP-700" + assert book.blueprint_effective_date == date(2026, 7, 21) + assert str(book.blueprint_url) == ( + "https://learn.microsoft.com/en-us/credentials/certifications/" + "resources/study-guides/dp-700" + ) + assert [(domain.title, domain.weight) for domain in book.domains] == [ + ("Implement and manage an analytics solution", "30\u201335%"), + ("Ingest and transform data", "30\u201335%"), + ("Monitor and optimize an analytics solution", "30\u201335%"), + ] + assert [len(domain.chapters) for domain in book.domains] == [4, 3, 3] + assert book.chapter_count == 10 + + +def test_every_dp700_chapter_maps_objectives_sources_and_content() -> None: + catalog = BookCatalog(CONTENT_ROOT) + book = catalog.load_book("dp700") + + for chapter in book.chapters: + assert chapter.objectives + assert chapter.source_ids + assert catalog.chapter_path(book, chapter).is_file() + + assert sum(len(chapter.objectives) for chapter in book.chapters) == 54 diff --git a/tests/content/test_markdown_renderer.py b/tests/content/test_markdown_renderer.py new file mode 100644 index 0000000..f7e5d5b --- /dev/null +++ b/tests/content/test_markdown_renderer.py @@ -0,0 +1,56 @@ +"""Safe, stable Markdown rendering tests.""" + +from pathlib import Path + +from study_reader.content.markdown import render_markdown + +FIXTURE = Path("tests/content/fixtures/representative.md") + + +def test_renderer_assigns_deterministic_ids_to_reflowable_blocks() -> None: + rendered = render_markdown(FIXTURE.read_text()) + + assert 'id="orientation"' in rendered + assert 'id="key-idea"' in rendered + assert 'id="orientation-list-1"' in rendered + assert 'id="orientation-table-1"' in rendered + assert 'id="orientation-code-1"' in rendered + assert rendered == render_markdown(FIXTURE.read_text()) + + +def test_explicit_block_id_survives_ordinary_text_edits() -> None: + before = "\nA short explanation." + after = "\nA clearer and longer explanation." + + assert 'id="durable"' in render_markdown(before) + assert 'id="durable"' in render_markdown(after) + + +def test_renderer_removes_raw_html_scripts_and_unsafe_urls() -> None: + rendered = render_markdown( + '\n\n' + '\n\n' + "[unsafe](javascript:alert(1))" + ) + + assert " None: + expected = Path("tests/content/fixtures/representative.html").read_text().strip() + + assert render_markdown(FIXTURE.read_text()).strip() == expected diff --git a/tests/integration/test_app_boundaries.py b/tests/integration/test_app_boundaries.py index 086519d..0d14fe1 100644 --- a/tests/integration/test_app_boundaries.py +++ b/tests/integration/test_app_boundaries.py @@ -62,7 +62,9 @@ def test_reader_and_admin_route_tables_are_separate(test_settings: Settings) -> assert reader_paths.isdisjoint(admin_paths) with TestClient(create_reader_app(test_settings)) as reader_client: - assert "Study Reader" in reader_client.get("/").text + response = reader_client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"].startswith("/books/") with TestClient(create_admin_app(test_settings)) as admin_client: assert admin_client.get("/admin/").json() == { diff --git a/tests/integration/test_reader_page.py b/tests/integration/test_reader_page.py new file mode 100644 index 0000000..4fc5ed3 --- /dev/null +++ b/tests/integration/test_reader_page.py @@ -0,0 +1,47 @@ +"""Reader vertical-slice integration tests.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from study_reader.config import Settings +from study_reader.reader.app import create_app + + +def reader_client() -> TestClient: + settings = Settings( + environment="test", + published_content_dir=Path("content/published"), + ) + return TestClient(create_app(settings)) + + +def test_home_opens_the_first_dp700_chapter() -> None: + with reader_client() as client: + response = client.get("/", follow_redirects=False) + + assert response.status_code == 307 + assert response.headers["location"] == "/books/dp700/chapters/workspace-settings" + + +def test_chapter_page_contains_mobile_reader_landmarks() -> None: + with reader_client() as client: + response = client.get("/books/dp700/chapters/workspace-settings") + + assert response.status_code == 200 + assert ( + '' + in response.text + ) + assert 'class="reader-layout"' in response.text + assert 'class="toc-details" open' in response.text + assert 'aria-label="DP-700 table of contents"' in response.text + assert "Configure Microsoft Fabric workspace settings" in response.text + assert "Open official DP-700 study guide" in response.text + assert "Copy Markdown" not in response.text + + +def test_unknown_book_and_chapter_return_not_found() -> None: + with reader_client() as client: + assert client.get("/books/missing/chapters/anything").status_code == 404 + assert client.get("/books/dp700/chapters/missing").status_code == 404 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..a38d88a --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,70 @@ +"""Reusable book-contract tests.""" + +from datetime import date + +import pytest +from pydantic import HttpUrl, ValidationError + +from study_reader.content.models import Book, Chapter, Domain, Objective, Source + + +def minimal_book() -> Book: + """Build a non-DP-700 book to prove the schema is exam-agnostic.""" + + return Book( + id="exam-xyz", + exam_code="XY-100", + title="Example certification reader", + blueprint_effective_date=date(2026, 1, 1), + blueprint_url=HttpUrl("https://learn.microsoft.com/example"), + sources=[ + Source( + id="official-guide", + title="Official guide", + url=HttpUrl("https://learn.microsoft.com/example"), + ) + ], + domains=[ + Domain( + id="design", + title="Design a solution", + weight="100%", + chapters=[ + Chapter( + id="design-basics", + slug="design-basics", + title="Design basics", + content_path="design-basics.md", + objectives=[ + Objective(id="design.choose", title="Choose a design") + ], + source_ids=["official-guide"], + ) + ], + ) + ], + ) + + +def test_book_contract_is_not_hard_coded_to_dp700() -> None: + book = minimal_book() + + assert book.exam_code == "XY-100" + assert book.chapter_count == 1 + assert book.chapter_by_slug("design-basics").id == "design-basics" + + +def test_book_rejects_duplicate_stable_identifiers() -> None: + book_data = minimal_book().model_dump() + book_data["domains"] = (*book_data["domains"], book_data["domains"][0]) + + with pytest.raises(ValidationError, match="domain identifiers must be unique"): + Book.model_validate(book_data) + + +def test_book_rejects_unknown_source_mapping() -> None: + book_data = minimal_book().model_dump() + book_data["domains"][0]["chapters"][0]["source_ids"] = ["missing"] + + with pytest.raises(ValidationError, match="unknown source"): + Book.model_validate(book_data) diff --git a/uv.lock b/uv.lock index 3ca9fba..73cc723 100644 --- a/uv.lock +++ b/uv.lock @@ -385,6 +385,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -448,6 +460,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "1.20.2" @@ -500,6 +521,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nh3" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-08-23T14:26:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/88/b594f0e86856b37e182fb663283da419eea6424972506e640e890885467f/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-23T14:25:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/1e/60/847a21339f095c4d4c655af31fa2d18b174585bcc210709facacc7ce205c/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-08-23T14:25:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/1a103e00aaf5e59f2dee4c2709aac609bb2d4bb74fddaf0dcfade11ed87b/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-08-23T14:25:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4a/e9c436089a0c80b928011ead0efd156aa7639a19b6064ef58dcedcab8369/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-08-23T14:25:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/04/5c/aa1468e3e281e78d2b3b7d762ccba59f681af355e971dbd255d5903f7b86/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-08-23T14:26:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/57d186d9d3dd38905dc12dddb3484406cdf6aa0b1ce33639a2d277d4ee1c/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-08-23T14:26:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/6b/53/097a5ad0b34b15d67a472ef849165a54209fa5fbd3e639801c6fe439ba28/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-08-23T14:26:03.897Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c57a2c70534418310889a65ccfac3525e62f0bc0a8613225903403755ce7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-08-23T14:26:05.105Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/efda1d0a611d940bdfde6893bde1ea6b7b7d48c31273aea48e35b822fd58/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-08-23T14:26:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/18/3ab564595cb88196f50d26e163ed0fd2acc731ab26ac615df91981885887/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-08-23T14:26:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/94/0d/c257754bf57f829f307aa226bbe136d3a1356b5a0d08324c7b6bd2a8aacd/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-08-23T14:26:09.025Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/a687e7091928806e514f89fa2666f25ec9bfe0a902fc4402b25e51ce408b/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-08-23T14:26:10.606Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/b0e6bef633549a23347d5462aa288fcc42381e7918482062ca3cb456242a/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-08-23T14:26:12.037Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/2a0921d45b20828708bcb56887e47dcf8cae13818de5bf9a01308d348712/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-08-23T14:26:13.34Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d1/9d70e0e418a48280ec0ddc6c1b08b4b1136ebcc31a1625e57ff5c665fa51/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-08-23T14:26:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/93/a7/02dd159d4e71f98607d8d4249cddb7561e77be1a8e4dec77d76e1b68fc99/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-08-23T14:26:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-23T14:26:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/3212c1a5b5745245d7f18885207bbddb34c56075f34dd682bd539aad55cc/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-08-23T14:26:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/9e36594efad6c290de4240d02cb2bd80c339a4ab1c4de66e599ffa6d9d81/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-08-23T14:26:19.908Z" }, + { url = "https://files.pythonhosted.org/packages/00/0c/1a8985fd43fea5530c0ac890b6f0b423770ee72f111b70b7a77f2dec243a/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-08-23T14:26:21.536Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5d/891e533b716cf00df76ad0ba6485dcfd14d59a6430a3cc99057c4c04004e/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-08-23T14:26:22.907Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/ae8c0782fce74fb6fcf7234bb3d4017f37ce181b4f9d29369eab21c50a04/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-08-23T14:26:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/c3423351e8d864ad756e85e15f0c01433361f14d34e4ed156482c0518f2a/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-08-23T14:26:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/478f153f1d7c0baaa3d1e8bb5fdcee3a6235f90fe44ea969a9d4e2b8c47a/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-08-23T14:26:26.932Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b9/34433ccb1f0fe6968dabbb7d4bf5721c6221878ef07832748c06655a6a80/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-23T14:26:28.294Z" }, + { url = "https://files.pythonhosted.org/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -770,7 +825,10 @@ source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "nh3" }, { name = "pydantic-settings" }, + { name = "pyyaml" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -781,13 +839,17 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.116,<1" }, { name = "jinja2", specifier = ">=3.1,<4" }, + { name = "markdown-it-py", specifier = ">=4,<5" }, + { name = "nh3", specifier = ">=0.3,<1" }, { name = "pydantic-settings", specifier = ">=2.10,<3" }, + { name = "pyyaml", specifier = ">=6,<7" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, ] @@ -798,6 +860,7 @@ dev = [ { name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest-cov", specifier = ">=6.2,<7" }, { name = "ruff", specifier = ">=0.12,<1" }, + { name = "types-pyyaml", specifier = ">=6.0,<7" }, ] [[package]] @@ -809,6 +872,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260815" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/72/b56089aeee6c496d969bac42376bedb6e3eeab4682e1018fa3137122f94b/types_pyyaml-6.0.12.20260815.tar.gz", hash = "sha256:28764110c9cf35846e733da32d8d734df7473c5dde9ef67c3b7332ec0e819858", size = 18545, upload-time = "2026-08-15T02:41:51.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/52/eefeba09be4ef2a1eb989eb92934561e8e502a6ee3c32654996e4be7e399/types_pyyaml-6.0.12.20260815-py3-none-any.whl", hash = "sha256:6f332212b7e191f3afd5016a713c510b6340593b7ebec573c7d5d20aa5386d3b", size = 21148, upload-time = "2026-08-15T02:41:50.555Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"