julee 0.1.5__py3-none-any.whl → 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. julee/docs/sphinx_hcd/__init__.py +146 -13
  2. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  3. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  4. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  5. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  6. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  7. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  8. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  9. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  10. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  11. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  12. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  13. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  14. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  15. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  16. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  17. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  18. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  19. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  20. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  21. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  22. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  23. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  24. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  25. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  26. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  27. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  28. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  29. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  30. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  31. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  32. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  33. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  34. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  35. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  36. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  37. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  38. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  39. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  40. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  41. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  42. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  43. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  44. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  45. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  46. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  47. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  48. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  49. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  50. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  51. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  52. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  53. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  54. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  55. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  56. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  57. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  58. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  59. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  60. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  61. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  62. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  63. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  64. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  65. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  66. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  67. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  68. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  69. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  70. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  71. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  72. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  73. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  74. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  75. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  76. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  77. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  78. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  79. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  80. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  81. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  82. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  83. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  84. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  85. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  86. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  87. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  88. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  89. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  90. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  91. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  92. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  93. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  94. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/METADATA +2 -1
  95. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/RECORD +98 -13
  96. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  97. julee/docs/sphinx_hcd/apps.py +0 -518
  98. julee/docs/sphinx_hcd/epics.py +0 -453
  99. julee/docs/sphinx_hcd/integrations.py +0 -310
  100. julee/docs/sphinx_hcd/journeys.py +0 -797
  101. julee/docs/sphinx_hcd/personas.py +0 -457
  102. julee/docs/sphinx_hcd/stories.py +0 -960
  103. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
  104. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
  105. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,349 @@
1
+ """App directives for sphinx_hcd.
2
+
3
+ Provides directives for rendering application information:
4
+ - define-app: Render app info from YAML manifest + derived data
5
+ - app-index: Generate index tables grouped by type
6
+ - apps-for-persona: List apps for a persona
7
+ """
8
+
9
+ from docutils import nodes
10
+
11
+ from ...domain.models.app import App, AppType
12
+ from ...domain.use_cases import (
13
+ get_epics_for_app,
14
+ get_journeys_for_app,
15
+ get_personas_for_app,
16
+ get_stories_for_app,
17
+ )
18
+ from ...utils import normalize_name, path_to_root, slugify
19
+ from .base import HCDDirective
20
+
21
+
22
+ class DefineAppPlaceholder(nodes.General, nodes.Element):
23
+ """Placeholder node for define-app, replaced at doctree-resolved."""
24
+
25
+ pass
26
+
27
+
28
+ class AppIndexPlaceholder(nodes.General, nodes.Element):
29
+ """Placeholder node for app-index, replaced at doctree-resolved."""
30
+
31
+ pass
32
+
33
+
34
+ class AppsForPersonaPlaceholder(nodes.General, nodes.Element):
35
+ """Placeholder node for apps-for-persona, replaced at doctree-resolved."""
36
+
37
+ pass
38
+
39
+
40
+ class DefineAppDirective(HCDDirective):
41
+ """Render app info from YAML manifest plus derived data.
42
+
43
+ Usage::
44
+
45
+ .. define-app:: credential-tool
46
+ """
47
+
48
+ required_arguments = 1
49
+
50
+ def run(self):
51
+ app_slug = self.arguments[0]
52
+
53
+ # Track documented apps in environment (for validation)
54
+ if not hasattr(self.env, "documented_apps"):
55
+ self.env.documented_apps = set()
56
+ self.env.documented_apps.add(app_slug)
57
+
58
+ # Return placeholder - rendering in doctree-resolved
59
+ node = DefineAppPlaceholder()
60
+ node["app_slug"] = app_slug
61
+ return [node]
62
+
63
+
64
+ class AppIndexDirective(HCDDirective):
65
+ """Generate index tables grouped by app type.
66
+
67
+ Usage::
68
+
69
+ .. app-index::
70
+ """
71
+
72
+ def run(self):
73
+ node = AppIndexPlaceholder()
74
+ return [node]
75
+
76
+
77
+ class AppsForPersonaDirective(HCDDirective):
78
+ """List apps for a specific persona.
79
+
80
+ Usage::
81
+
82
+ .. apps-for-persona:: Member Implementer
83
+ """
84
+
85
+ required_arguments = 1
86
+ final_argument_whitespace = True
87
+
88
+ def run(self):
89
+ node = AppsForPersonaPlaceholder()
90
+ node["persona"] = self.arguments[0]
91
+ return [node]
92
+
93
+
94
+ def build_app_content(app_slug: str, docname: str, hcd_context):
95
+ """Build the content nodes for an app."""
96
+ from sphinx.addnodes import seealso
97
+
98
+ from ...config import get_config
99
+
100
+ config = get_config()
101
+ prefix = path_to_root(docname)
102
+
103
+ # Get app from repository
104
+ app = hcd_context.app_repo.get(app_slug)
105
+ if not app:
106
+ para = nodes.paragraph()
107
+ para += nodes.problematic(text=f"App '{app_slug}' not found in apps/")
108
+ return [para]
109
+
110
+ # Get all entities for cross-references
111
+ all_stories = hcd_context.story_repo.list_all()
112
+ all_epics = hcd_context.epic_repo.list_all()
113
+ all_journeys = hcd_context.journey_repo.list_all()
114
+
115
+ result_nodes = []
116
+
117
+ # Description first
118
+ if app.description:
119
+ desc_para = nodes.paragraph()
120
+ desc_para += nodes.Text(app.description)
121
+ result_nodes.append(desc_para)
122
+
123
+ # Stories count and link
124
+ app_stories = get_stories_for_app(app, all_stories)
125
+
126
+ if app_stories:
127
+ story_count = len(app_stories)
128
+ stories_para = nodes.paragraph()
129
+ stories_para += nodes.Text(f"The {app.name} has ")
130
+ story_path = f"{prefix}{config.get_doc_path('stories')}/{app_slug}.html"
131
+ ref = nodes.reference("", "", refuri=story_path)
132
+ ref += nodes.Text(f"{story_count} stories")
133
+ stories_para += ref
134
+ stories_para += nodes.Text(".")
135
+ result_nodes.append(stories_para)
136
+
137
+ # Build seealso box with metadata
138
+ seealso_node = seealso()
139
+
140
+ # Type
141
+ type_para = nodes.paragraph()
142
+ type_para += nodes.strong(text="Type: ")
143
+ type_para += nodes.Text(app.type_label)
144
+ seealso_node += type_para
145
+
146
+ # Status (if present)
147
+ if app.status:
148
+ status_para = nodes.paragraph()
149
+ status_para += nodes.strong(text="Status: ")
150
+ status_para += nodes.Text(app.status)
151
+ seealso_node += status_para
152
+
153
+ # Personas (derived from stories)
154
+ personas = get_personas_for_app(app, all_stories, all_epics)
155
+ if personas:
156
+ persona_para = nodes.paragraph()
157
+ persona_para += nodes.strong(text="Personas: ")
158
+ for i, persona in enumerate(personas):
159
+ persona_slug = slugify(persona.name)
160
+ persona_path = (
161
+ f"{prefix}{config.get_doc_path('personas')}/{persona_slug}.html"
162
+ )
163
+ ref = nodes.reference("", "", refuri=persona_path)
164
+ ref += nodes.Text(persona.name)
165
+ persona_para += ref
166
+ if i < len(personas) - 1:
167
+ persona_para += nodes.Text(", ")
168
+ seealso_node += persona_para
169
+
170
+ # Related Journeys
171
+ journeys = get_journeys_for_app(app, all_stories, all_journeys)
172
+ if journeys:
173
+ journey_para = nodes.paragraph()
174
+ journey_para += nodes.strong(text="Journeys: ")
175
+ for i, journey in enumerate(journeys):
176
+ journey_path = (
177
+ f"{prefix}{config.get_doc_path('journeys')}/{journey.slug}.html"
178
+ )
179
+ ref = nodes.reference("", "", refuri=journey_path)
180
+ ref += nodes.Text(journey.slug.replace("-", " ").title())
181
+ journey_para += ref
182
+ if i < len(journeys) - 1:
183
+ journey_para += nodes.Text(", ")
184
+ seealso_node += journey_para
185
+
186
+ # Related Epics
187
+ epics = get_epics_for_app(app, all_stories, all_epics)
188
+ if epics:
189
+ epic_para = nodes.paragraph()
190
+ epic_para += nodes.strong(text="Epics: ")
191
+ for i, epic in enumerate(epics):
192
+ epic_path = f"{prefix}{config.get_doc_path('epics')}/{epic.slug}.html"
193
+ ref = nodes.reference("", "", refuri=epic_path)
194
+ ref += nodes.Text(epic.slug.replace("-", " ").title())
195
+ epic_para += ref
196
+ if i < len(epics) - 1:
197
+ epic_para += nodes.Text(", ")
198
+ seealso_node += epic_para
199
+
200
+ result_nodes.append(seealso_node)
201
+
202
+ return result_nodes
203
+
204
+
205
+ def build_app_index(docname: str, hcd_context):
206
+ """Build the app index grouped by type."""
207
+ all_apps = hcd_context.app_repo.list_all()
208
+ all_stories = hcd_context.story_repo.list_all()
209
+
210
+ if not all_apps:
211
+ para = nodes.paragraph()
212
+ para += nodes.emphasis(text="No apps defined")
213
+ return [para]
214
+
215
+ # Group apps by type
216
+ by_type: dict[AppType, list[App]] = {
217
+ AppType.STAFF: [],
218
+ AppType.EXTERNAL: [],
219
+ AppType.MEMBER_TOOL: [],
220
+ }
221
+
222
+ for app in all_apps:
223
+ if app.app_type in by_type:
224
+ by_type[app.app_type].append(app)
225
+ else:
226
+ by_type.setdefault(app.app_type, []).append(app)
227
+
228
+ result_nodes = []
229
+
230
+ type_sections = [
231
+ (AppType.STAFF, "Staff Applications"),
232
+ (AppType.EXTERNAL, "External Applications"),
233
+ (AppType.MEMBER_TOOL, "Member Tools"),
234
+ ]
235
+
236
+ for type_key, type_label in type_sections:
237
+ apps = by_type.get(type_key, [])
238
+ if not apps:
239
+ continue
240
+
241
+ # Section heading
242
+ heading = nodes.paragraph()
243
+ heading += nodes.strong(text=type_label)
244
+ result_nodes.append(heading)
245
+
246
+ # App list
247
+ app_list = nodes.bullet_list()
248
+
249
+ for app in sorted(apps, key=lambda a: a.name):
250
+ # Get personas for this app
251
+ app_stories = get_stories_for_app(app, all_stories)
252
+ personas = {s.persona for s in app_stories}
253
+
254
+ item = nodes.list_item()
255
+ para = nodes.paragraph()
256
+
257
+ # Link to app
258
+ app_path = f"{app.slug}.html"
259
+ ref = nodes.reference("", "", refuri=app_path)
260
+ ref += nodes.Text(app.name)
261
+ para += ref
262
+
263
+ # Personas
264
+ if personas:
265
+ para += nodes.Text(f" ({', '.join(sorted(personas))})")
266
+
267
+ item += para
268
+ app_list += item
269
+
270
+ result_nodes.append(app_list)
271
+
272
+ return result_nodes
273
+
274
+
275
+ def build_apps_for_persona(docname: str, persona_arg: str, hcd_context):
276
+ """Build list of apps for a persona."""
277
+ from ...config import get_config
278
+ from ...domain.use_cases import derive_personas, get_apps_for_persona
279
+
280
+ config = get_config()
281
+ prefix = path_to_root(docname)
282
+ persona_normalized = normalize_name(persona_arg)
283
+
284
+ all_apps = hcd_context.app_repo.list_all()
285
+ all_stories = hcd_context.story_repo.list_all()
286
+ all_epics = hcd_context.epic_repo.list_all()
287
+
288
+ # Derive personas
289
+ personas = derive_personas(all_stories, all_epics)
290
+
291
+ # Find the persona
292
+ persona = None
293
+ for p in personas:
294
+ if p.normalized_name == persona_normalized:
295
+ persona = p
296
+ break
297
+
298
+ if not persona:
299
+ para = nodes.paragraph()
300
+ para += nodes.emphasis(text=f"No apps found for persona '{persona_arg}'")
301
+ return [para]
302
+
303
+ # Get apps for this persona
304
+ matching_apps = get_apps_for_persona(persona, all_apps)
305
+
306
+ if not matching_apps:
307
+ para = nodes.paragraph()
308
+ para += nodes.emphasis(text=f"No apps found for persona '{persona_arg}'")
309
+ return [para]
310
+
311
+ bullet_list = nodes.bullet_list()
312
+
313
+ for app in sorted(matching_apps, key=lambda a: a.name):
314
+ item = nodes.list_item()
315
+ para = nodes.paragraph()
316
+
317
+ app_path = f"{prefix}{config.get_doc_path('applications')}/{app.slug}.html"
318
+ ref = nodes.reference("", "", refuri=app_path)
319
+ ref += nodes.Text(app.name)
320
+ para += ref
321
+
322
+ item += para
323
+ bullet_list += item
324
+
325
+ return [bullet_list]
326
+
327
+
328
+ def process_app_placeholders(app, doctree, docname):
329
+ """Replace app placeholders with rendered content."""
330
+ from ..context import get_hcd_context
331
+
332
+ hcd_context = get_hcd_context(app)
333
+
334
+ # Process define-app placeholders
335
+ for node in doctree.traverse(DefineAppPlaceholder):
336
+ app_slug = node["app_slug"]
337
+ content = build_app_content(app_slug, docname, hcd_context)
338
+ node.replace_self(content)
339
+
340
+ # Process app-index placeholders
341
+ for node in doctree.traverse(AppIndexPlaceholder):
342
+ content = build_app_index(docname, hcd_context)
343
+ node.replace_self(content)
344
+
345
+ # Process apps-for-persona placeholders
346
+ for node in doctree.traverse(AppsForPersonaPlaceholder):
347
+ persona = node["persona"]
348
+ content = build_apps_for_persona(docname, persona, hcd_context)
349
+ node.replace_self(content)
@@ -0,0 +1,211 @@
1
+ """Base directive and utilities for sphinx_hcd directives.
2
+
3
+ Provides common functionality for building docutils nodes and accessing
4
+ the HCDContext repositories.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from docutils import nodes
10
+ from sphinx.util.docutils import SphinxDirective
11
+
12
+ from ...config import get_config
13
+ from ...utils import path_to_root, slugify
14
+ from ..context import HCDContext, get_hcd_context
15
+
16
+ if TYPE_CHECKING:
17
+ pass
18
+
19
+
20
+ class HCDDirective(SphinxDirective):
21
+ """Base directive with HCD context access.
22
+
23
+ All HCD directives inherit from this to get easy access to:
24
+ - HCDContext with all repositories
25
+ - Config for path resolution
26
+ - Common node-building utilities
27
+ """
28
+
29
+ @property
30
+ def hcd_context(self) -> HCDContext:
31
+ """Get the HCD context from Sphinx app."""
32
+ return get_hcd_context(self.env.app)
33
+
34
+ @property
35
+ def hcd_config(self):
36
+ """Get the HCD configuration."""
37
+ return get_config()
38
+
39
+ @property
40
+ def docname(self) -> str:
41
+ """Get the current document name."""
42
+ return self.env.docname
43
+
44
+ @property
45
+ def prefix(self) -> str:
46
+ """Get relative path prefix to docs root."""
47
+ return path_to_root(self.docname)
48
+
49
+ def get_doc_path(self, doc_type: str) -> str:
50
+ """Get the path for a documentation type with prefix."""
51
+ return f"{self.prefix}{self.hcd_config.get_doc_path(doc_type)}"
52
+
53
+ def make_link(
54
+ self,
55
+ text: str,
56
+ path: str,
57
+ strong: bool = False,
58
+ ) -> nodes.reference:
59
+ """Create a reference node with text.
60
+
61
+ Args:
62
+ text: Link text
63
+ path: Target path (relative or absolute)
64
+ strong: Whether to make text bold
65
+
66
+ Returns:
67
+ Reference node
68
+ """
69
+ ref = nodes.reference("", "", refuri=path)
70
+ if strong:
71
+ ref += nodes.strong(text=text)
72
+ else:
73
+ ref += nodes.Text(text)
74
+ return ref
75
+
76
+ def make_app_link(self, app_slug: str) -> nodes.reference:
77
+ """Create a link to an app page."""
78
+ app_name = app_slug.replace("-", " ").title()
79
+ app_path = f"{self.get_doc_path('applications')}/{app_slug}.html"
80
+ return self.make_link(app_name, app_path)
81
+
82
+ def make_persona_link(self, persona_name: str) -> nodes.reference:
83
+ """Create a link to a persona page."""
84
+ persona_slug = slugify(persona_name)
85
+ persona_path = f"{self.get_doc_path('personas')}/{persona_slug}.html"
86
+ return self.make_link(persona_name, persona_path)
87
+
88
+ def make_epic_link(self, epic_slug: str) -> nodes.reference:
89
+ """Create a link to an epic page."""
90
+ epic_name = epic_slug.replace("-", " ").title()
91
+ epic_path = f"{self.get_doc_path('epics')}/{epic_slug}.html"
92
+ return self.make_link(epic_name, epic_path)
93
+
94
+ def make_journey_link(self, journey_slug: str) -> nodes.reference:
95
+ """Create a link to a journey page."""
96
+ journey_name = journey_slug.replace("-", " ").title()
97
+ journey_path = f"{self.get_doc_path('journeys')}/{journey_slug}.html"
98
+ return self.make_link(journey_name, journey_path)
99
+
100
+ def make_story_link(
101
+ self,
102
+ story,
103
+ link_text: str | None = None,
104
+ ) -> nodes.reference:
105
+ """Create a link to a story on its app's story page.
106
+
107
+ Args:
108
+ story: Story entity or dict with app, slug, i_want
109
+ link_text: Optional link text (defaults to i_want)
110
+
111
+ Returns:
112
+ Reference node linking to story anchor
113
+ """
114
+ # Handle both Story entities and legacy dicts
115
+ if hasattr(story, "app_slug"):
116
+ app_slug = story.app_slug
117
+ story_slug = story.slug
118
+ default_text = story.i_want
119
+ else:
120
+ app_slug = story.get("app", story.get("app_slug"))
121
+ story_slug = story.get("slug")
122
+ default_text = story.get("i_want", story.get("feature_title", ""))
123
+
124
+ config = self.hcd_config
125
+ target_doc = f"{config.get_doc_path('stories')}/{app_slug}"
126
+ ref_uri = self._build_relative_uri(target_doc, story_slug)
127
+
128
+ ref = nodes.reference("", "", refuri=ref_uri)
129
+ ref += nodes.Text(link_text or default_text)
130
+ return ref
131
+
132
+ def _build_relative_uri(
133
+ self,
134
+ target_doc: str,
135
+ anchor: str | None = None,
136
+ ) -> str:
137
+ """Build a relative URI from current doc to target.
138
+
139
+ Args:
140
+ target_doc: Target document path (without .html)
141
+ anchor: Optional anchor within target
142
+
143
+ Returns:
144
+ Relative URI string
145
+ """
146
+ from_parts = self.docname.split("/")
147
+ target_parts = target_doc.split("/")
148
+
149
+ # Find common prefix
150
+ common = 0
151
+ for i in range(min(len(from_parts), len(target_parts))):
152
+ if from_parts[i] == target_parts[i]:
153
+ common += 1
154
+ else:
155
+ break
156
+
157
+ # Build relative path
158
+ up_levels = len(from_parts) - common - 1
159
+ down_path = "/".join(target_parts[common:])
160
+
161
+ if up_levels > 0:
162
+ rel_path = "../" * up_levels + down_path + ".html"
163
+ else:
164
+ rel_path = down_path + ".html"
165
+
166
+ if anchor:
167
+ return f"{rel_path}#{anchor}"
168
+ return rel_path
169
+
170
+ def empty_result(self, message: str) -> list[nodes.Node]:
171
+ """Create an emphasized message for empty results."""
172
+ para = nodes.paragraph()
173
+ para += nodes.emphasis(text=message)
174
+ return [para]
175
+
176
+ def warning_node(self, message: str) -> nodes.paragraph:
177
+ """Create a warning paragraph with problematic text."""
178
+ para = nodes.paragraph()
179
+ para += nodes.problematic(text=f"[{message}]")
180
+ return para
181
+
182
+
183
+ def make_deprecated_directive(
184
+ base_class: type,
185
+ old_name: str,
186
+ new_name: str,
187
+ ) -> type:
188
+ """Create a deprecated alias directive that warns and delegates.
189
+
190
+ Args:
191
+ base_class: The directive class to wrap
192
+ old_name: The deprecated directive name
193
+ new_name: The new directive name
194
+
195
+ Returns:
196
+ A new directive class that warns and delegates
197
+ """
198
+ from sphinx.util import logging
199
+
200
+ logger = logging.getLogger(__name__)
201
+
202
+ class DeprecatedDirective(base_class):
203
+ def run(self):
204
+ logger.warning(
205
+ f"Directive '{old_name}' is deprecated, use '{new_name}' instead. "
206
+ f"(in {self.env.docname})"
207
+ )
208
+ return super().run()
209
+
210
+ DeprecatedDirective.__name__ = f"Deprecated{base_class.__name__}"
211
+ return DeprecatedDirective