Skip to content

Link Preview Module

Build-time preview payloads and static asset helpers for same-site link preview.

Called from plugin core when preview_config.enabled is true. Link path normalization is shared with the graph via mkdocs_note.utils.links.

Same-site link hover preview: build-time JSON and static asset helpers.

See issue #82. Opt-in via preview_config.enabled; when disabled the plugin must not register assets, inject scripts, or write previews.json.

PreviewBuilder

Build previews.json payloads from documentation pages.

Source code in src/mkdocs_note/preview.py
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
class PreviewBuilder:
	"""Build ``previews.json`` payloads from documentation pages."""

	def __init__(
		self,
		preview_config: dict[str, Any],
		*,
		site_base: str = "/",
		markdown_extensions: Any = None,
		mdx_configs: dict[str, Any] | None = None,
		extra_src_paths: set[str] | None = None,
		index_src_paths: set[str] | None = None,
		graph_src_paths: set[str] | None = None,
	):
		self.config = preview_config
		self.site_base = site_base if site_base.endswith("/") else site_base + "/"
		self.mode = preview_config.get("mode", "summary")
		self.max_chars = int(preview_config.get("max_chars", 200))
		self.include_fragments = bool(preview_config.get("include_fragments", True))
		self.scope = preview_config.get("scope", "linked_only")
		self.data: dict[str, dict[str, Any]] = {}
		self._md = create_preview_markdown(markdown_extensions, mdx_configs)
		self.extra_src_paths = set(extra_src_paths or ())
		self.index_src_paths = set(index_src_paths or ())
		self.graph_src_paths = set(graph_src_paths or ())
		self._files: Files | None = None

	def _normalize_page_url(self, page_url: str) -> str:
		"""Canonicalize MkDocs ``file.url`` for JSON keys.

		Percent-decodes path segments so keys match runtime decoded lookups
		(CJK directories etc.). Homepage ``""`` / ``.`` / ``./`` → empty string.
		"""
		url = (page_url or "").strip().lstrip("/")
		try:
			url = unquote(url)
		except (ValueError, TypeError):
			pass
		if url in {"", ".", "./"}:
			return ""
		if not url.endswith("/") and "." not in url.rsplit("/", 1)[-1]:
			url += "/"
		return url

	def _page_key(self, page_url: str, fragment: str = "") -> str:
		url = self._normalize_page_url(page_url)
		frag = decode_fragment(fragment)
		if frag:
			return f"{url}#{frag}" if url else f"#{frag}"
		return url

	def _html(self, section_md: str, page_url: str, source_file: Any = None) -> str:
		return excerpt_html(
			section_md,
			page_url,
			self.site_base,
			md=self._md,
			source_file=source_file,
			files=self._files,
		)

	def __call__(self, files: Files) -> dict[str, dict[str, Any]]:
		"""Scan files and return the preview mapping."""
		logger.info("Building link previews...")
		self._files = files
		docs = [f for f in files.documentation_pages() if f.page]
		known = {f.src_path for f in docs}
		src_to_file = {f.src_path: f for f in docs}

		linked: set[str] = set()
		linked_fragments: set[tuple[str, str]] = set()

		def _collect_from_file(f) -> None:
			try:
				text = Path(f.abs_src_path).read_text(encoding="utf-8")
			except OSError:
				return
			for target_src, frag in find_link_targets(text, f.src_path, known):
				linked.add(target_src)
				decoded = decode_fragment(frag)
				if decoded:
					linked_fragments.add((target_src, decoded))

		if self.scope == "linked_only":
			for f in docs:
				_collect_from_file(f)
			# Expand: recent notes, notes-index out-links, soft graph nodes
			linked |= {p for p in self.extra_src_paths if p in known}
			for idx_src in self.index_src_paths:
				idx_file = src_to_file.get(idx_src)
				if idx_file is not None:
					_collect_from_file(idx_file)
			linked |= {p for p in self.graph_src_paths if p in known}
			targets = [src_to_file[s] for s in linked if s in src_to_file]
		else:
			targets = docs
			if self.include_fragments:
				for f in docs:
					_collect_from_file(f)

		for f in targets:
			self._add_page(f, linked_fragments)

		logger.info(f"Created {len(self.data)} preview entries")
		return self.data

	def _add_page(self, f, linked_fragments: set[tuple[str, str]]) -> None:
		try:
			raw = Path(f.abs_src_path).read_text(encoding="utf-8")
		except OSError as e:
			logger.warning(f"Cannot read {f.abs_src_path}: {e}")
			return

		meta, body = parse_frontmatter(raw)
		if meta.get("preview") is False:
			return

		page_url = f.url  # e.g. notes/foo/
		title = extract_title_from_page(
			meta,
			body,
			fallback=f.page.title if f.page and f.page.title else f.name,
		)
		summary = extract_summary(meta, body, self.max_chars)
		preview_image = meta.get("preview_image") or meta.get("image")
		if not isinstance(preview_image, str):
			preview_image = None
		elif preview_image:
			preview_image = _rewrite_url(
				preview_image,
				page_url,
				self.site_base,
				source_file=f,
				files=self._files,
			)

		entry: dict[str, Any] = {
			"title": title,
			"summary": summary,
		}
		if preview_image:
			entry["image"] = preview_image

		key = self._page_key(page_url)
		page_entry = dict(entry)

		if self.mode == "excerpt":
			sections_list = _split_sections(body)
			lead_md = next((s[2] for s in sections_list if not s[0]), body)
			page_entry["excerpt"] = excerpt_plain(lead_md, self.max_chars)
			page_entry["html"] = self._html(lead_md, page_url, source_file=f)

		self.data[key] = page_entry

		if not self.include_fragments or self.mode != "excerpt":
			return

		sections = {sid: (text, md) for sid, text, md in _split_sections(body) if sid}
		if self.scope == "linked_only":
			frag_ids = {
				decode_fragment(frag)
				for src, frag in linked_fragments
				if src == f.src_path
			}
		else:
			frag_ids = set(sections.keys())

		for frag in frag_ids:
			if not frag or frag not in sections:
				continue
			heading_text, section_md = resolve_section_markdown(sections, frag)
			plain = excerpt_plain(section_md, self.max_chars)
			html_body = self._html(section_md, page_url, source_file=f)
			frag_entry: dict[str, Any] = {
				"title": f"{title} · {heading_text}",
				"summary": plain or summary,
				"excerpt": plain,
				"html": html_body,
			}
			# Avoid misleading page summary when excerpt/html are empty
			if not plain and not (html_body or "").strip():
				frag_entry["summary"] = ""
				frag_entry["excerpt"] = ""
			if preview_image:
				frag_entry["image"] = preview_image
			self.data[self._page_key(page_url, frag)] = frag_entry

__call__(files)

Scan files and return the preview mapping.

Source code in src/mkdocs_note/preview.py
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
def __call__(self, files: Files) -> dict[str, dict[str, Any]]:
	"""Scan files and return the preview mapping."""
	logger.info("Building link previews...")
	self._files = files
	docs = [f for f in files.documentation_pages() if f.page]
	known = {f.src_path for f in docs}
	src_to_file = {f.src_path: f for f in docs}

	linked: set[str] = set()
	linked_fragments: set[tuple[str, str]] = set()

	def _collect_from_file(f) -> None:
		try:
			text = Path(f.abs_src_path).read_text(encoding="utf-8")
		except OSError:
			return
		for target_src, frag in find_link_targets(text, f.src_path, known):
			linked.add(target_src)
			decoded = decode_fragment(frag)
			if decoded:
				linked_fragments.add((target_src, decoded))

	if self.scope == "linked_only":
		for f in docs:
			_collect_from_file(f)
		# Expand: recent notes, notes-index out-links, soft graph nodes
		linked |= {p for p in self.extra_src_paths if p in known}
		for idx_src in self.index_src_paths:
			idx_file = src_to_file.get(idx_src)
			if idx_file is not None:
				_collect_from_file(idx_file)
		linked |= {p for p in self.graph_src_paths if p in known}
		targets = [src_to_file[s] for s in linked if s in src_to_file]
	else:
		targets = docs
		if self.include_fragments:
			for f in docs:
				_collect_from_file(f)

	for f in targets:
		self._add_page(f, linked_fragments)

	logger.info(f"Created {len(self.data)} preview entries")
	return self.data

add_preview_static_resources(config)

Register preview JS/CSS on the MkDocs config (call only when enabled).

Source code in src/mkdocs_note/preview.py
883
884
885
886
887
888
def add_preview_static_resources(config: MkDocsConfig) -> None:
	"""Register preview JS/CSS on the MkDocs config (call only when enabled)."""
	if "js/preview.js" not in config["extra_javascript"]:
		config["extra_javascript"].append("js/preview.js")
	if "css/preview.css" not in config["extra_css"]:
		config["extra_css"].append("css/preview.css")

copy_preview_static_assets(static_dir, config)

Copy preview.js / preview.css into the site directory.

Source code in src/mkdocs_note/preview.py
914
915
916
917
918
919
920
921
922
def copy_preview_static_assets(static_dir: str, config: MkDocsConfig) -> None:
	"""Copy preview.js / preview.css into the site directory."""
	js_output_dir = os.path.join(config["site_dir"], "js")
	os.makedirs(js_output_dir, exist_ok=True)
	shutil.copy(os.path.join(static_dir, "preview.js"), js_output_dir)

	css_output_dir = os.path.join(config["site_dir"], "css")
	os.makedirs(css_output_dir, exist_ok=True)
	shutil.copy(os.path.join(static_dir, "preview.css"), css_output_dir)

create_preview_markdown(markdown_extensions=None, mdx_configs=None)

Create a Python-Markdown instance aligned with the site config.

Loads extensions incrementally so one broken/plugin-only extension cannot force a full fallback to the basic set.

Source code in src/mkdocs_note/preview.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def create_preview_markdown(
	markdown_extensions: Any = None,
	mdx_configs: dict[str, Any] | None = None,
):
	"""Create a Python-Markdown instance aligned with the site config.

	Loads extensions incrementally so one broken/plugin-only extension cannot
	force a full fallback to the basic set.
	"""
	import markdown

	exts, configs = prepare_preview_markdown_config(markdown_extensions, mdx_configs)
	working: list[str] = list(_FALLBACK_EXTENSIONS)
	md = markdown.Markdown(extensions=working)

	for ext in exts:
		if ext in working:
			continue
		trial = [*working, ext]
		trial_configs = {k: configs[k] for k in trial if k in configs}
		try:
			md = markdown.Markdown(extensions=trial, extension_configs=trial_configs)
		except (
			ImportError,
			ValueError,
			TypeError,
			AttributeError,
			KeyError,
			OSError,
		) as exc:
			logger.debug("Skipping markdown extension %s for preview: %s", ext, exc)
			continue
		working = trial

	if working != list(_FALLBACK_EXTENSIONS):
		logger.info(
			"Preview markdown extensions: %s",
			", ".join(working),
		)
	return md

decode_fragment(fragment)

Normalize a URL fragment to decoded Unicode (no leading #).

Source code in src/mkdocs_note/preview.py
674
675
676
677
678
679
680
681
682
def decode_fragment(fragment: str) -> str:
	"""Normalize a URL fragment to decoded Unicode (no leading ``#``)."""
	frag = (fragment or "").lstrip("#")
	if not frag:
		return ""
	try:
		return unquote(frag)
	except (ValueError, TypeError):
		return frag

excerpt_html(section_md, page_url, site_base, *, md=None, markdown_extensions=None, mdx_configs=None, source_file=None, files=None)

Build a sanitized rich HTML excerpt from section markdown.

When md or site extension config is provided, rendering follows the MkDocs/Material markdown stack (minus unsafe/noisy extensions).

Source code in src/mkdocs_note/preview.py
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
def excerpt_html(
	section_md: str,
	page_url: str,
	site_base: str,
	*,
	md: Any | None = None,
	markdown_extensions: Any = None,
	mdx_configs: dict[str, Any] | None = None,
	source_file: Any | None = None,
	files: Any | None = None,
) -> str:
	"""Build a sanitized rich HTML excerpt from section markdown.

	When ``md`` or site extension config is provided, rendering follows the
	MkDocs/Material markdown stack (minus unsafe/noisy extensions).
	"""
	converter = md
	if converter is None:
		converter = create_preview_markdown(markdown_extensions, mdx_configs)
	else:
		converter.reset()
	raw = converter.convert(section_md)
	raw = _rewrite_html_urls(
		raw,
		page_url,
		site_base,
		source_file=source_file,
		files=files,
	)
	return sanitize_preview_html(raw)

excerpt_plain(section_md, max_chars)

Plain-text excerpt: prose + fenced code as text, truncated.

Source code in src/mkdocs_note/preview.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def excerpt_plain(section_md: str, max_chars: int) -> str:
	"""Plain-text excerpt: prose + fenced code as text, truncated."""
	parts: list[str] = []
	in_fence = False
	fence_lines: list[str] = []
	for line in section_md.splitlines():
		if _FENCE_RE.match(line.strip()):
			if in_fence:
				parts.append("\n".join(fence_lines))
				fence_lines = []
				in_fence = False
			else:
				in_fence = True
			continue
		if in_fence:
			fence_lines.append(line)
			continue
		stripped = line.strip()
		if not stripped or stripped.startswith("#"):
			continue
		parts.append(strip_markdown_inline(stripped))
	if fence_lines:
		parts.append("\n".join(fence_lines))
	text = "\n\n".join(p for p in parts if p)
	if len(text) > max_chars:
		return text[: max_chars - 1].rstrip() + "…"
	return text

extract_summary(meta, body, max_chars)

Summary priority: frontmatter description → summary → first paragraph.

Source code in src/mkdocs_note/preview.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def extract_summary(
	meta: dict[str, Any],
	body: str,
	max_chars: int,
) -> str:
	"""Summary priority: frontmatter description → summary → first paragraph."""
	for key in ("description", "summary"):
		val = meta.get(key)
		if isinstance(val, str) and val.strip():
			text = strip_markdown_inline(val.strip())
			if len(text) > max_chars:
				return text[: max_chars - 1].rstrip() + "…"
			return text
	return first_prose_paragraph(body, max_chars)

extract_title_from_page(meta, body, fallback)

Resolve a display title from frontmatter, first H1, or fallback.

Source code in src/mkdocs_note/preview.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def extract_title_from_page(
	meta: dict[str, Any],
	body: str,
	fallback: str,
) -> str:
	"""Resolve a display title from frontmatter, first H1, or fallback."""
	title = meta.get("title")
	if isinstance(title, str) and title.strip():
		return title.strip()
	m = _HEADING_RE.search(body)
	if m and len(m.group(1)) == 1:
		return strip_markdown_inline(m.group(2))
	return fallback

first_prose_paragraph(body, max_chars)

Extract the first non-empty prose paragraph from markdown body.

Skips ATX headings, blockquotes, tables, lists, and Material admonition / details / tab markers (!!!, ???, ===) plus their indented bodies.

Source code in src/mkdocs_note/preview.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def first_prose_paragraph(body: str, max_chars: int) -> str:
	"""Extract the first non-empty prose paragraph from markdown body.

	Skips ATX headings, blockquotes, tables, lists, and Material admonition /
	details / tab markers (``!!!``, ``???``, ``===``) plus their indented bodies.
	"""
	# Drop fenced code blocks so we don't preview code as summary.
	without_code = _CODE_FENCE_BLOCK_RE.sub("", body)
	chunks: list[str] = []
	buf: list[str] = []
	skip_indented_block = False
	for line in without_code.splitlines():
		stripped = line.strip()
		if not stripped:
			if buf:
				chunks.append(" ".join(buf))
				buf = []
			skip_indented_block = False
			continue
		# Admonition / details / tab openers and indented continuation lines.
		if stripped.startswith(("!!!", "???", "===")):
			if buf:
				chunks.append(" ".join(buf))
				buf = []
			skip_indented_block = True
			continue
		if skip_indented_block and line.startswith(("    ", "\t")):
			continue
		skip_indented_block = False
		if stripped.startswith(("#", ">", "|")):
			if buf:
				chunks.append(" ".join(buf))
				buf = []
			continue
		if stripped.startswith(("- ", "* ")) or re.match(r"^\d+\.\s", stripped):
			if buf:
				chunks.append(" ".join(buf))
				buf = []
			continue
		buf.append(stripped)
	if buf:
		chunks.append(" ".join(buf))

	for chunk in chunks:
		plain = strip_markdown_inline(chunk)
		if plain and not plain.startswith(("!!!", "???", "===")):
			if len(plain) > max_chars:
				return plain[: max_chars - 1].rstrip() + "…"
			return plain
	return ""

inject_preview_script(output, config, preview_config, *, graph_enabled=False)

Inject window.preview_options before </body>.

Source code in src/mkdocs_note/preview.py
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
def inject_preview_script(
	output: str,
	config: MkDocsConfig,
	preview_config: dict[str, Any],
	*,
	graph_enabled: bool = False,
) -> str:
	"""Inject ``window.preview_options`` before ``</body>``."""
	base_path = _base_path_from_config(config)
	options = {
		"base_path": base_path,
		"mode": preview_config.get("mode", "summary"),
		"delay_ms": int(preview_config.get("delay_ms", 300)),
		"mobile": bool(preview_config.get("mobile", False)),
		"graph_enabled": graph_enabled,
	}
	payload = json.dumps(options)
	options_script = f"<script>window.preview_options = {payload};</script>"
	if "</body>" in output:
		return output.replace("</body>", f"{options_script}</body>")
	return output

prepare_preview_markdown_config(markdown_extensions=None, mdx_configs=None)

Filter site markdown extensions for hover-preview rendering.

Reuses the MkDocs/Material extension stack where practical, but drops toc (permalink clutter), pymdownx.snippets (arbitrary includes), and plugin-only extensions such as mkdocstrings.

Source code in src/mkdocs_note/preview.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def prepare_preview_markdown_config(
	markdown_extensions: Any = None,
	mdx_configs: dict[str, Any] | None = None,
) -> tuple[list[str], dict[str, Any]]:
	"""Filter site markdown extensions for hover-preview rendering.

	Reuses the MkDocs/Material extension stack where practical, but drops
	``toc`` (permalink clutter), ``pymdownx.snippets`` (arbitrary includes),
	and plugin-only extensions such as mkdocstrings.
	"""
	raw_exts: list[Any] = list(markdown_extensions or [])
	configs = dict(mdx_configs or {})
	kept: list[str] = []
	kept_names: set[str] = set()
	for ext in raw_exts:
		name = _normalize_extension_name(ext)
		if name is None or not _is_preview_safe_extension(name):
			if name:
				configs.pop(name, None)
			continue
		kept.append(name)
		kept_names.add(name)

	if not kept:
		kept = list(_FALLBACK_EXTENSIONS)
		kept_names = set(kept)
	else:
		has_superfences = "pymdownx.superfences" in kept_names
		for required in _FALLBACK_EXTENSIONS:
			if required in kept_names:
				continue
			if required == "fenced_code" and has_superfences:
				continue
			kept.append(required)
			kept_names.add(required)

	configs = {k: v for k, v in configs.items() if k in kept_names}
	return kept, configs

resolve_section_markdown(sections, frag)

Return (heading_text, section_md) for a fragment.

If the section has no extractable prose, descend into the first nested child with content; otherwise return the hierarchical section body (which already includes nested headings when split with same-or-higher rules).

Source code in src/mkdocs_note/preview.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def resolve_section_markdown(
	sections: dict[str, tuple[str, str]],
	frag: str,
) -> tuple[str, str]:
	"""Return ``(heading_text, section_md)`` for a fragment.

	If the section has no extractable prose, descend into the first nested
	child with content; otherwise return the hierarchical section body (which
	already includes nested headings when split with same-or-higher rules).
	"""
	if frag not in sections:
		return "", ""
	heading_text, section_md = sections[frag]
	if _section_has_prose(section_md):
		return heading_text, section_md
	nested = _split_sections(section_md)
	for sid, _child_text, child_md in nested:
		if not sid:
			continue
		if _section_has_prose(child_md):
			return heading_text, child_md
	# Keep hierarchical body even if prose extractor is empty (e.g. only code).
	return heading_text, section_md

sanitize_preview_html(raw)

Sanitize HTML to the preview allow-list.

Source code in src/mkdocs_note/preview.py
631
632
633
634
635
636
637
638
639
def sanitize_preview_html(raw: str) -> str:
	"""Sanitize HTML to the preview allow-list."""
	parser = _PreviewHTMLSanitizer()
	try:
		parser.feed(raw)
		parser.close()
	except (ValueError, TypeError, AssertionError):
		return html.escape(raw)
	return parser.result()

slugify_heading(text)

Slugify a heading title to match Material / pymdownx TOC ids.

Source code in src/mkdocs_note/preview.py
59
60
61
62
def slugify_heading(text: str) -> str:
	"""Slugify a heading title to match Material / pymdownx TOC ids."""
	fn = _get_slugify()
	return fn(text, "-")

strip_markdown_inline(text)

Remove common inline markdown markers for plain-text summaries.

Source code in src/mkdocs_note/preview.py
65
66
67
68
69
70
71
72
73
def strip_markdown_inline(text: str) -> str:
	"""Remove common inline markdown markers for plain-text summaries."""
	text = _MD_IMAGE_RE.sub(r"\1", text)
	text = _MD_LINK_RE.sub(r"\1", text)
	text = _MD_BOLD_RE.sub(r"\2", text)
	text = _MD_ITALIC_RE.sub(r"\2", text)
	text = _MD_INLINE_CODE_RE.sub(r"\1", text)
	text = _MD_HTML_TAG_RE.sub("", text)
	return re.sub(r"\s+", " ", text).strip()

write_previews_file(data, config)

Write site/previews/previews.json.

Source code in src/mkdocs_note/preview.py
925
926
927
928
929
930
931
932
933
934
935
def write_previews_file(
	data: dict[str, dict[str, Any]],
	config: MkDocsConfig,
) -> None:
	"""Write ``site/previews/previews.json``."""
	output_dir = os.path.join(config["site_dir"], "previews")
	os.makedirs(output_dir, exist_ok=True)
	path = os.path.join(output_dir, "previews.json")
	with open(path, "w", encoding="utf-8") as f:
		json.dump(data, f, ensure_ascii=False)
	logger.info(f"Wrote previews to {path}")