Skip to content

Graph Visualization Module

This section was the underlying module of the network graph visualization feature which migrated from MkDocs Network Graph Plugin.

It will be called by plugin core directly.

Graph data structure and manipulation.

Migrated from mkdocs-network-graph-plugin.

Graph

Represents the connection graph between files.

Source code in src/mkdocs_note/graph.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 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
class Graph:
	"""Represents the connection graph between files."""

	LINK_PATTERN = r"\[[^\]]+\]\((?P<url>.*?)\)|\[\[(?P<wikilink>[^\]]+)\]\]"

	def __init__(self, config):
		"""Initializes the graph data structure."""
		if config.get("debug", False):
			logger.setLevel("DEBUG")
		logger.debug("Graph initialized")
		self.nodes = []
		self.edges = []
		self.config = config

	def _create_nodes(self, files: Files):
		"""Create nodes from the file collection."""
		logger.debug("Creating nodes...")
		documentation_pages = list(files.documentation_pages())
		logger.debug(f"Found {len(documentation_pages)} documentation pages")
		for file in documentation_pages:
			if file.page:
				name = self._get_name_from_config(file.page)
				self.nodes.append(
					{
						"id": file.src_path,
						"path": file.abs_src_path,
						"name": name,
						"url": file.url,
					},
				)
		logger.info(f"Created {len(self.nodes)} nodes")

	def _get_name_from_config(self, page: Page) -> str:
		"""Return the name of the node based on the plugin configuration."""
		if self.config["name"] == "title":
			logger.debug(f"Using 'title' for node name for page '{page.title}'")
			if "title" in page.meta:
				return str(page.meta["title"])
			if page.title is not None:
				return str(page.title)
		logger.debug(f"Using 'file_name' for node name for page '{page.title}'")
		return page.file.name

	def _unescape_url(self, url: str) -> str:
		"""Unescape a URL."""
		# Strip angle brackets if present (for links like [text](<url>))
		if url.startswith("<") and url.endswith(">"):
			url = url[1:-1]
		return unquote(url)

	def _normalize_link(self, match: re.Match) -> Optional[str]:
		"""Normalize the URL from a regex match."""
		url = match.group("url") or match.group("wikilink")
		if not url:
			return None

		# For wikilinks, add the .md extension
		if match.group("wikilink") and not url.endswith(".md"):
			url += ".md"
		url = self._unescape_url(url)

		# Remove query and fragment from the URL
		url = urlsplit(url).path

		return url

	def _find_links(self, markdown: str, node_id: str, files: Files) -> Iterator[dict]:
		"""Find all links in a markdown string and yield resolved edges."""
		for match in re.finditer(self.LINK_PATTERN, markdown):
			url = self._normalize_link(match)
			if not url:
				continue

			target_path = os.path.normpath(os.path.join(os.path.dirname(node_id), url))

			# Check if the target is a node in the graph
			if any(node["id"] == target_path for node in self.nodes):
				yield {"source": node_id, "target": target_path}

	def _create_edges(self, files: Files):
		"""Create edges by parsing links from markdown files."""
		logger.debug("Creating edges...")
		for node in self.nodes:
			logger.debug(f"Parsing file {node['path']} for links")
			try:
				with open(node["path"], "r", encoding="utf-8") as f:
					markdown = f.read()
				self.edges.extend(self._find_links(markdown, node["id"], files))
			except FileNotFoundError:
				logger.warning(f"File not found: {node['path']}")
				# This should not happen if the file is in the `files` collection
				pass
		logger.info(f"Created {len(self.edges)} edges")

	def __call__(self, files: Files):
		"""Build the graph from the file collection."""
		logger.info("Building graph...")
		self._create_nodes(files)
		self._create_edges(files)
		return self

	def to_dict(self):
		"""Return the graph as a dictionary."""
		return {"nodes": self.nodes, "edges": self.edges}

__call__(files)

Build the graph from the file collection.

Source code in src/mkdocs_note/graph.py
114
115
116
117
118
119
def __call__(self, files: Files):
	"""Build the graph from the file collection."""
	logger.info("Building graph...")
	self._create_nodes(files)
	self._create_edges(files)
	return self

__init__(config)

Initializes the graph data structure.

Source code in src/mkdocs_note/graph.py
25
26
27
28
29
30
31
32
def __init__(self, config):
	"""Initializes the graph data structure."""
	if config.get("debug", False):
		logger.setLevel("DEBUG")
	logger.debug("Graph initialized")
	self.nodes = []
	self.edges = []
	self.config = config

to_dict()

Return the graph as a dictionary.

Source code in src/mkdocs_note/graph.py
121
122
123
def to_dict(self):
	"""Return the graph as a dictionary."""
	return {"nodes": self.nodes, "edges": self.edges}

add_static_resouces(config)

Add static resources into mkdocs config for network graph.

Parameters:

Name Type Description Default
config MkDocsConfig

The MkDocs configuration.

required
Source code in src/mkdocs_note/graph.py
126
127
128
129
130
131
132
133
134
135
136
137
def add_static_resouces(config: MkDocsConfig) -> None:
	"""Add static resources into mkdocs config for network graph.

	Args:
		config (MkDocsConfig): The MkDocs configuration.
	"""
	config["extra_javascript"].append("https://d3js.org/d3.v7.min.js")

	if "js/graph.js" not in config["extra_javascript"]:
		config["extra_javascript"].append("js/graph.js")
	if "css/graph.css" not in config["extra_css"]:
		config["extra_css"].append("css/graph.css")

copy_static_assets(static_dir, config)

Copy static assets into the site directory.

Parameters:

Name Type Description Default
config MkDocsConfig

The MkDocs configuration.

required
Source code in src/mkdocs_note/graph.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def copy_static_assets(static_dir: str, config: MkDocsConfig) -> None:
	"""Copy static assets into the site directory.

	Args:
		config (MkDocsConfig): The MkDocs configuration.
	"""
	# Copy JS
	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, "graph.js"), js_output_dir)

	# Copy CSS
	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, "graph.css"), css_output_dir)

inject_graph_script(output, config, debug=False)

Inject the graph script into the HTML page.

Parameters:

Name Type Description Default
output str

The HTML output.

required
config MkDocsConfig

The MkDocs configuration.

required
debug bool

Whether to enable debug mode.

False

Returns:

Name Type Description
str str

The HTML with the graph script injected.

Source code in src/mkdocs_note/graph.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def inject_graph_script(output: str, config: MkDocsConfig, debug: bool = False) -> str:
	"""Inject the graph script into the HTML page.

	Args:
		output (str): The HTML output.
		config (MkDocsConfig): The MkDocs configuration.
		debug (bool): Whether to enable debug mode.

	Returns:
		str: The HTML with the graph script injected.
	"""
	site_url = config.get("site_url")
	if site_url:
		base_path = urlparse(site_url).path
		# Ensure base_path ends with a slash
		if not base_path.endswith("/"):
			base_path += "/"
	else:
		base_path = "/"

	options_script = (
		"<script>"
		f"window.graph_options = {{"
		f"    debug: {str(debug).lower()},"
		f"    base_path: '{base_path}'"
		f"}};"
		"</script>"
	)
	if "</body>" in output:
		return output.replace("</body>", f"{options_script}</body>")
	return output