Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
hrefresolver.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
4#include "hrefresolver.h"
5
6#ifdef QDOC_TEMPLATE_GENERATOR_ENABLED
7
8#include "anchorid.h"
9#include "collectionnode.h"
10#include "inclusionfilter.h"
11#include "outputcontext.h"
12#include "qmltypenode.h"
13#include "tree.h"
14#include "utilities.h"
15
16#include <QtCore/qdir.h>
17#include <QtCore/qfileinfo.h>
18
19QT_BEGIN_NAMESPACE
20
21/*!
22 \struct HrefResolverConfig
23 \internal
24 \brief Configuration for HrefResolver URL computation.
25
26 The non-owning \c context pointer is the single source of truth for
27 output layout (project name, file extension, per-genus prefixes and
28 suffixes, subdir usage, diagnostic gating). It must outlive the
29 HrefResolver.
30
31 The \c cleanRefFn callback sanitizes anchor references. It
32 corresponds to Generator::cleanRef() in the legacy path.
33
34 The \c qmlTypeContextFn callback returns the current QML type
35 being documented, enabling property inheritance resolution for
36 abstract QML types. An empty callback disables this resolution.
37*/
38
39/*!
40 \class HrefResolver
41 \internal
42 \brief Computes URLs for documentation nodes without Generator dependencies.
43
44 Not thread-safe: the mutable fileBase cache is written from const
45 methods. Create one instance per thread or synchronize externally.
46*/
47
48HrefResolver::HrefResolver(const HrefResolverConfig &config)
49 : m_config(config)
50{
51}
52
53/*!
54 Computes the filename stem for \a node without mutating the node.
55 Results are cached in m_fileBaseCache to avoid recomputation.
56
57 This mirrors Generator::fileBase() but uses an external QHash cache
58 instead of const_cast on the Node.
59*/
60QString HrefResolver::fileBase(const Node *node) const
61{
62 if (!node->isPageNode() && !node->isCollectionNode()) {
63 node = node->parent();
64 if (!node)
65 return {};
66 }
67
68 if (node->hasFileNameBase())
69 return node->fileNameBase();
70
71 auto it = m_fileBaseCache.constFind(node);
72 if (it != m_fileBaseCache.constEnd())
73 return it.value();
74
75 const OutputContext *ctx = m_config.context;
76 QString result = Utilities::computeFileBase(
77 node, ctx->project,
78 [ctx](const Node *n) { return ctx->outputPrefix(n->genus()); },
79 [ctx](const Node *n) { return ctx->outputSuffix(n->genus()); });
80
81 m_fileBaseCache.insert(node, result);
82 return result;
83}
84
85/*!
86 Computes the output filename for \a node. Returns the node's
87 explicit URL if one is set, otherwise constructs a filename
88 from fileBase() and the configured file extension.
89
90 This mirrors Generator::fileName() without depending on a
91 Generator instance.
92*/
93QString HrefResolver::fileName(const Node *node) const
94{
95 if (!node->url().isEmpty())
96 return node->url();
97
98 const auto base = fileBase(node);
99 if (base.isEmpty())
100 return {};
101
102 if (node->isTextPageNode() && !node->isCollectionNode()) {
103 QFileInfo originalName(node->name());
104 QString suffix = originalName.suffix();
105 if (!suffix.isEmpty() && suffix != "html"_L1)
106 return base + '.'_L1 + suffix;
107 }
108
109 return base + '.'_L1 + m_config.context->fileExtension;
110}
111
112/*!
113 Computes the anchor fragment for \a node based on its type.
114 Returns an empty string for page-level nodes that don't need
115 anchors.
116
117 Delegates to the free function computeAnchorId() for node-type
118 dispatch, then applies the configured cleanRefFn for sanitization.
119 This keeps the shared anchor logic generator-agnostic while
120 allowing each caller to control cleanup policy.
121*/
122QString HrefResolver::anchorForNode(const Node *node) const
123{
124 const QString ref = computeAnchorId(node);
125 if (ref.isEmpty())
126 return ref;
127 return m_config.cleanRefFn ? m_config.cleanRefFn(ref) : ref;
128}
129
130/*!
131 Computes a relative URL for \a node, relative to \a relative.
132 Returns the URL as a QString on success, or an HrefSuppressReason
133 explaining why linking should be suppressed.
134
135 External pages (declared via \c \\externalpage) pass through
136 unchanged, since their URL points by design at a resource outside
137 the current documentation set. Every other reference — including
138 URLs loaded from a dependency's \c .index file or authored with an
139 explicit \c \\url that names an absolute address such as
140 \c https://doc.qt.io/... — is reduced to a bare filename stem and
141 then prefixed with whatever relative path reaches the target
142 module's output directory from the current page's output
143 directory. The prefix is derived from OutputContext and the two
144 nodes' trees, not from a hardcoded \c ../<module>/ pattern, so it
145 comes out correct regardless of whether the qdocconf uses
146 per-module subdirs, a format subdirectory, or both.
147*/
148HrefResult HrefResolver::hrefForNode(const Node *node, const Node *relative) const
149{
150 if (node == nullptr)
151 return HrefSuppressReason::NullNode;
152
153 const bool hasExplicitUrl = !node->url().isEmpty();
154 if (hasExplicitUrl && node->isExternalPage())
155 return node->url();
156
157 // An explicit URL arrives with a directory prefix baked in by the
158 // reader that produced it (qdocindexfiles prepends ../<module>/).
159 // That prefix encodes one specific layout's depth assumption and
160 // is wrong for any other layout, so discard it and recompute from
161 // the current output paths below.
162 QString fn = hasExplicitUrl
163 ? node->url().section('/'_L1, -1)
164 : fileName(node);
165 // Strip any anchor from an explicit URL: anchorForNode below is the
166 // single source of truth for the final anchor. Without this, members
167 // of cross-tree pages get the baked anchor preserved and a fresh
168 // anchor appended, producing href values like
169 // `qwindow.html#devicePixelRatio#devicePixelRatio`.
170 if (hasExplicitUrl) {
171 const qsizetype hashIndex = fn.indexOf('#'_L1);
172 if (hashIndex >= 0)
173 fn.truncate(hashIndex);
174 }
175 if (fn.isEmpty())
176 return HrefSuppressReason::NoFileBase;
177
178 // A cross-module collection placeholder carries content authored in
179 // another module; its location identity (tree()) points at the
180 // consumer module, but its content-origin identity lives on the
181 // resolvedPhysicalModuleName field set during mergeCollections.
182 // Treat it the same as an index-loaded node: bypass InclusionFilter
183 // and fire the cross-module prefix.
184 const bool isCrossModulePlaceholder = node->isCollectionNode()
185 && !static_cast<const CollectionNode *>(node)
186 ->resolvedPhysicalModuleName().isEmpty();
187
188 // Index-loaded nodes carry partial metadata (e.g., a placeholder
189 // Doc), so InclusionFilter can reject upstream-published entities
190 // on synthetic flags. Skip the check for them. Cross-module
191 // collection placeholders share that semantic without the
192 // isIndexNode flag, so admit them too.
193 if (!node->isIndexNode() && !isCrossModulePlaceholder) {
194 const NodeContext context = node->createContext();
195 if (!InclusionFilter::isIncluded(m_config.inclusionPolicy, context))
196 return HrefSuppressReason::ExcludedByPolicy;
197 }
198
199 if (!hasExplicitUrl && node->parent() && node->parent()->isQmlType()
200 && node->parent()->isAbstract()) {
201 const QmlTypeNode *qmlContext = m_config.qmlTypeContextFn
202 ? m_config.qmlTypeContextFn() : nullptr;
203 if (qmlContext) {
204 if (qmlContext->inherits(node->parent())) {
205 fn = fileName(qmlContext);
206 } else if (node->parent()->isInternal() && !m_config.context->noLinkErrors) {
207 node->doc().location().warning(
208 u"Cannot link to property in internal type '%1'"_s
209 .arg(node->parent()->name()));
210 return HrefSuppressReason::InternalAbstractQml;
211 }
212 }
213 }
214
215 QString link = fn;
216
217 if (!node->isPageNode() || node->isPropertyGroup()) {
218 QString ref = anchorForNode(node);
219 if (relative && fn == fileName(relative) && ref == anchorForNode(relative))
220 return HrefSuppressReason::SameFileAnchor;
221
222 link += '#'_L1;
223 link += ref;
224 }
225
226 if (relative && (node != relative) && !node->isExternalPage()
227 && (node->isIndexNode() || isCrossModulePlaceholder
228 || node->tree() != relative->tree())) {
229 link.prepend(crossModulePrefix(node, relative));
230 }
231
232 return link;
233}
234
235/*!
236 \internal
237 Returns a relative-path prefix (ending in \c /) that reaches \a
238 target's output directory from \a source's output directory, or
239 an empty string when the two share a directory.
240
241 The current page's output directory is the OutputContext's \c
242 outputDir. The target's output directory is derived by locating
243 the current module's name as a segment in that path and swapping
244 in the target module's name. If the current module's name isn't
245 present — a flat layout where multiple modules render into the
246 same directory — source and target share a directory and no
247 prefix is needed.
248*/
249QString HrefResolver::crossModulePrefix(const Node *target, const Node *source) const
250{
251 const QString &sourceDir = m_config.context->outputDir.path();
252 const QString currentSegment = '/'_L1 + source->tree()->physicalModuleName() + '/'_L1;
253 const qsizetype moduleIndex = sourceDir.indexOf(currentSegment);
254 if (moduleIndex < 0)
255 return {};
256
257 // Cross-module collection placeholders live in the consumer tree but
258 // mirror content from another. Prefer the recorded upstream module
259 // name so the computed prefix reflects the content origin instead of
260 // the placeholder's location.
261 QString targetModuleName;
262 if (target->isCollectionNode()) {
263 const auto *cn = static_cast<const CollectionNode *>(target);
264 targetModuleName = cn->resolvedPhysicalModuleName();
265 }
266 if (targetModuleName.isEmpty())
267 targetModuleName = target->tree()->physicalModuleName();
268 const QString targetSegment = '/'_L1 + targetModuleName + '/'_L1;
269 QString targetDir = sourceDir;
270 targetDir.replace(moduleIndex, currentSegment.size(), targetSegment);
271
272 const QString relPath = QDir(sourceDir).relativeFilePath(targetDir);
273 if (relPath.isEmpty() || relPath == "."_L1)
274 return {};
275 return relPath + '/'_L1;
276}
277
278QT_END_NAMESPACE
279
280#endif // QDOC_TEMPLATE_GENERATOR_ENABLED