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 hrefForNode() prefers the \c relative page when it is a QML type,
38 because that page is the member's rendered documentation owner and
39 the callback may hold stale state from another generator.
40*/
41
42/*!
43 \class HrefResolver
44 \internal
45 \brief Computes URLs for documentation nodes without Generator dependencies.
46
47 Not thread-safe: the mutable fileBase cache is written from const
48 methods. Create one instance per thread or synchronize externally.
49*/
50
51HrefResolver::HrefResolver(const HrefResolverConfig &config)
52 : m_config(config)
53{
54}
55
56/*!
57 Computes the filename stem for \a node without mutating the node.
58 Results are cached in m_fileBaseCache to avoid recomputation.
59
60 This mirrors Generator::fileBase() but uses an external QHash cache
61 instead of const_cast on the Node.
62
63 Like HtmlGenerator::fileBase(), the stem of a deprecated
64 non-aggregate node (a member or a page) carries an "-obsolete"
65 suffix. Deprecated members are documented on their aggregate's
66 obsolete sub-page, so links to them must use the suffixed name to
67 resolve there. Aggregates keep their own page name.
68*/
69QString HrefResolver::fileBase(const Node *node) const
70{
71 const bool obsoleteFileBase = !node->isAggregate() && node->isDeprecated();
72
73 if (!node->isPageNode() && !node->isCollectionNode()) {
74 node = node->parent();
75 if (!node)
76 return {};
77 }
78
79 QString result;
80 if (node->hasFileNameBase()) {
81 result = node->fileNameBase();
82 } else {
83 auto it = m_fileBaseCache.constFind(node);
84 if (it != m_fileBaseCache.constEnd()) {
85 result = it.value();
86 } else {
87 const OutputContext *ctx = m_config.context;
88 result = Utilities::computeFileBase(
89 node, ctx->project,
90 [ctx](const Node *n) { return ctx->outputPrefix(n->genus()); },
91 [ctx](const Node *n) { return ctx->outputSuffix(n->genus()); });
92 m_fileBaseCache.insert(node, result);
93 }
94 }
95
96 if (obsoleteFileBase)
97 result += "-obsolete"_L1;
98 return result;
99}
100
101/*!
102 Computes the output filename for \a node. Returns the node's
103 explicit URL if one is set, otherwise constructs a filename
104 from fileBase() and the configured file extension.
105
106 This mirrors Generator::fileName() without depending on a
107 Generator instance.
108*/
109QString HrefResolver::fileName(const Node *node) const
110{
111 if (!node->url().isEmpty())
112 return node->url();
113
114 const auto base = fileBase(node);
115 if (base.isEmpty())
116 return {};
117
118 if (node->isTextPageNode() && !node->isCollectionNode()) {
119 QFileInfo originalName(node->name());
120 QString suffix = originalName.suffix();
121 if (!suffix.isEmpty() && suffix != "html"_L1)
122 return base + '.'_L1 + suffix;
123 }
124
125 return base + '.'_L1 + m_config.context->fileExtension;
126}
127
128/*!
129 Computes the anchor fragment for \a node based on its type.
130 Returns an empty string for page-level nodes that don't need
131 anchors.
132
133 Delegates to the free function computeAnchorId() for node-type
134 dispatch, then applies the configured cleanRefFn for sanitization.
135 This keeps the shared anchor logic generator-agnostic while
136 allowing each caller to control cleanup policy.
137*/
138QString HrefResolver::anchorForNode(const Node *node) const
139{
140 const QString ref = computeAnchorId(node);
141 if (ref.isEmpty())
142 return ref;
143 return m_config.cleanRefFn ? m_config.cleanRefFn(ref) : ref;
144}
145
146/*!
147 Computes a relative URL for \a node, relative to \a relative.
148 Returns the URL as a QString on success, or an HrefSuppressReason
149 explaining why linking should be suppressed.
150
151 External pages (declared via \c \\externalpage) pass through
152 unchanged, since their URL points by design at a resource outside
153 the current documentation set. Every other reference — including
154 URLs loaded from a dependency's \c .index file or authored with an
155 explicit \c \\url that names an absolute address such as
156 \c https://doc.qt.io/... — is reduced to a bare filename stem and
157 then prefixed with whatever relative path reaches the target
158 module's output directory from the current page's output
159 directory. The prefix is derived from OutputContext and the two
160 nodes' trees, not from a hardcoded \c ../<module>/ pattern, so it
161 comes out correct regardless of whether the qdocconf uses
162 per-module subdirs, a format subdirectory, or both.
163*/
164HrefResult HrefResolver::hrefForNode(const Node *node, const Node *relative) const
165{
166 if (node == nullptr)
167 return HrefSuppressReason::NullNode;
168
169 const bool hasExplicitUrl = !node->url().isEmpty();
170 if (hasExplicitUrl && node->isExternalPage())
171 return node->url();
172
173 // An explicit URL arrives with a directory prefix baked in by the
174 // reader that produced it (qdocindexfiles prepends ../<module>/).
175 // That prefix encodes one specific layout's depth assumption and
176 // is wrong for any other layout, so discard it and recompute from
177 // the current output paths below.
178 QString fn = hasExplicitUrl
179 ? node->url().section('/'_L1, -1)
180 : fileName(node);
181 // Strip any anchor from an explicit URL: anchorForNode below is the
182 // single source of truth for the final anchor. Without this, members
183 // of cross-tree pages get the baked anchor preserved and a fresh
184 // anchor appended, producing href values like
185 // `qwindow.html#devicePixelRatio#devicePixelRatio`.
186 if (hasExplicitUrl) {
187 const qsizetype hashIndex = fn.indexOf('#'_L1);
188 if (hashIndex >= 0)
189 fn.truncate(hashIndex);
190 }
191 if (fn.isEmpty())
192 return HrefSuppressReason::NoFileBase;
193
194 // A cross-module collection placeholder carries content authored in
195 // another module; its location identity (tree()) points at the
196 // consumer module, but its content-origin identity lives on the
197 // resolvedPhysicalModuleName field set during mergeCollections.
198 // Treat it the same as an index-loaded node: bypass InclusionFilter
199 // and fire the cross-module prefix.
200 const bool isCrossModulePlaceholder = node->isCollectionNode()
201 && !static_cast<const CollectionNode *>(node)
202 ->resolvedPhysicalModuleName().isEmpty();
203
204 // Index-loaded nodes carry partial metadata (e.g., a placeholder
205 // Doc), so InclusionFilter can reject upstream-published entities
206 // on synthetic flags. Skip the check for them. Cross-module
207 // collection placeholders share that semantic without the
208 // isIndexNode flag, so admit them too.
209 if (!node->isIndexNode() && !isCrossModulePlaceholder) {
210 const NodeContext context = node->createContext();
211 if (!InclusionFilter::isIncluded(m_config.inclusionPolicy, context))
212 return HrefSuppressReason::ExcludedByPolicy;
213 }
214
215 if (!hasExplicitUrl && node->parent() && node->parent()->isQmlType()
216 && node->parent()->isAbstract()) {
217 // The legacy generators record the QML type they are generating in
218 // Generator's mutable state and expose it through qmlTypeContextFn.
219 // Template generators never establish that state, so prefer the
220 // page being rendered when it is a QML type, and fall back to the
221 // callback for contexts the callback owns (for instance legacy
222 // cross-format runs).
223 const QmlTypeNode *qmlContext = nullptr;
224 if (relative && relative->isQmlType())
225 qmlContext = static_cast<const QmlTypeNode *>(relative);
226 else if (m_config.qmlTypeContextFn)
227 qmlContext = m_config.qmlTypeContextFn();
228 if (qmlContext) {
229 if (qmlContext->inherits(node->parent())) {
230 fn = fileName(qmlContext);
231 // fileBase() gives a deprecated member an "-obsolete"
232 // suffix so it resolves to the type's obsolete sub-page.
233 // The remap above replaced that stem with the type's main
234 // page name, so re-apply the suffix when the member is
235 // deprecated and the target is a computed name rather than
236 // an explicit \url.
237 if (!node->isAggregate() && node->isDeprecated()
238 && qmlContext->url().isEmpty()) {
239 const QString ext = '.'_L1 + m_config.context->fileExtension;
240 fn.chop(ext.length());
241 fn += "-obsolete"_L1 + ext;
242 }
243 } else if (node->parent()->isInternal() && !m_config.context->noLinkErrors) {
244 node->doc().location().warning(
245 u"Cannot link to property in internal type '%1'"_s
246 .arg(node->parent()->name()));
247 return HrefSuppressReason::InternalAbstractQml;
248 }
249 }
250 }
251
252 QString link = fn;
253
254 if (!node->isPageNode() || node->isPropertyGroup()) {
255 QString ref = anchorForNode(node);
256 if (relative && fn == fileName(relative) && ref == anchorForNode(relative))
257 return HrefSuppressReason::SameFileAnchor;
258
259 link += '#'_L1;
260 link += ref;
261 }
262
263 if (relative && (node != relative) && !node->isExternalPage()
264 && (node->isIndexNode() || isCrossModulePlaceholder
265 || node->tree() != relative->tree())) {
266 link.prepend(crossModulePrefix(node, relative));
267 }
268
269 return link;
270}
271
272/*!
273 \internal
274 Returns a relative-path prefix (ending in \c /) that reaches \a
275 target's output directory from \a source's output directory, or
276 an empty string when the two share a directory.
277
278 The current page's output directory is the OutputContext's \c
279 outputDir. The target's output directory is derived by locating
280 the current module's name as a segment in that path and swapping
281 in the target module's name. If the current module's name isn't
282 present — a flat layout where multiple modules render into the
283 same directory — source and target share a directory and no
284 prefix is needed.
285*/
286QString HrefResolver::crossModulePrefix(const Node *target, const Node *source) const
287{
288 const QString &sourceDir = m_config.context->outputDir.path();
289 const QString currentSegment = '/'_L1 + source->tree()->physicalModuleName() + '/'_L1;
290 const qsizetype moduleIndex = sourceDir.indexOf(currentSegment);
291 if (moduleIndex < 0)
292 return {};
293
294 // Cross-module collection placeholders live in the consumer tree but
295 // mirror content from another. Prefer the recorded upstream module
296 // name so the computed prefix reflects the content origin instead of
297 // the placeholder's location.
298 QString targetModuleName;
299 if (target->isCollectionNode()) {
300 const auto *cn = static_cast<const CollectionNode *>(target);
301 targetModuleName = cn->resolvedPhysicalModuleName();
302 }
303 if (targetModuleName.isEmpty())
304 targetModuleName = target->tree()->physicalModuleName();
305 const QString targetSegment = '/'_L1 + targetModuleName + '/'_L1;
306 QString targetDir = sourceDir;
307 targetDir.replace(moduleIndex, currentSegment.size(), targetSegment);
308
309 const QString relPath = QDir(sourceDir).relativeFilePath(targetDir);
310 if (relPath.isEmpty() || relPath == "."_L1)
311 return {};
312 return relPath + '/'_L1;
313}
314
315QT_END_NAMESPACE
316
317#endif // QDOC_TEMPLATE_GENERATOR_ENABLED