Study Reader
" - "The mobile-first reading experience is under construction.
" - "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"The mobile-first reading experience is under construction.
" - "Domain {{ book.domains.index(domain) + 1 }} · {{ domain.weight }}
+{{ domain.title }}
+Authoritative source
+This reader summarizes and teaches. Microsoft Learn remains the source of truth.
+Choose a setting deliberately. Check the official guide before relying on memory.
+| Setting | +Question | +
|---|---|
| OneLake | +Who 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'
+ '