Skip to content

Commit db4f226

Browse files
authored
Merge pull request #2841 from jcollins-g/nnbd-mainmerge-1020
Merge head to NNBD
2 parents c8cd148 + c469e70 commit db4f226

File tree

12 files changed

+369
-16
lines changed

12 files changed

+369
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 4.1.0-dev
2+
* Experimental feature: HTML output from markdown rendering, `{@tool}` and
3+
`{@inject-html}` is sanitized when hidden option `--sanitize-html` is passed.
4+
15
## 4.0.0
26
* BREAKING CHANGE: Refactors to support NNBD and adapt to new analyzer
37
changes are technically semver breaking. If you make extensive use of

dartdoc_options.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
dartdoc:
22
linkToSource:
33
root: '.'
4-
uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v4.0.0/%f%#L%l%'
4+
uriTemplate: 'https://github.com/dart-lang/dartdoc/blob/v4.1.0-dev/%f%#L%l%'

lib/src/dartdoc_options.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,8 @@ class DartdocOptionContext extends DartdocOptionContextBase
12691269

12701270
bool get injectHtml => optionSet['injectHtml'].valueAt(context);
12711271

1272+
bool get sanitizeHtml => optionSet['sanitizeHtml'].valueAt(context);
1273+
12721274
bool get excludeFooterVersion =>
12731275
optionSet['excludeFooterVersion'].valueAt(context);
12741276

@@ -1418,6 +1420,10 @@ Future<List<DartdocOption>> createDartdocOptions(
14181420
DartdocOptionArgOnly<bool>('injectHtml', false, resourceProvider,
14191421
help: 'Allow the use of the {@inject-html} directive to inject raw '
14201422
'HTML into dartdoc output.'),
1423+
DartdocOptionArgOnly<bool>('sanitizeHtml', false, resourceProvider,
1424+
hide: true,
1425+
help: 'Sanitize HTML generated from markdown, {@tool} and '
1426+
'{@inject-html} directives.'),
14211427
DartdocOptionArgOnly<String>(
14221428
'input', resourceProvider.pathContext.current, resourceProvider,
14231429
optionIs: OptionKind.dir,

lib/src/generator/templates.runtime_renderers.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15304,6 +15304,7 @@ const _invisibleGetters = {
1530415304
'includeExternal',
1530515305
'includeSource',
1530615306
'injectHtml',
15307+
'sanitizeHtml',
1530715308
'excludeFooterVersion',
1530815309
'tools',
1530915310
'inputDir',

lib/src/model/documentation.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ class Documentation {
5353
}
5454
_hasExtendedDocs = parseResult.hasExtendedDocs;
5555

56-
var renderResult =
57-
_renderer.render(parseResult.nodes, processFullDocs: processFullDocs);
56+
var renderResult = _renderer.render(parseResult.nodes,
57+
processFullDocs: processFullDocs,
58+
sanitizeHtml: _element.config.sanitizeHtml);
5859

5960
if (processFullDocs) {
6061
_asHtml = renderResult.asHtml;

lib/src/render/documentation_renderer.dart

Lines changed: 269 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5-
import 'package:html/parser.dart' show parse;
5+
import 'package:html/dom.dart' as dom;
6+
import 'package:html/parser.dart' show parseFragment;
7+
68
import 'package:markdown/markdown.dart' as md;
79

810
abstract class DocumentationRenderer {
911
DocumentationRenderResult render(
1012
List<md.Node> nodes, {
1113
required bool processFullDocs,
14+
required bool sanitizeHtml,
1215
});
1316
}
1417

@@ -19,16 +22,16 @@ class DocumentationRendererHtml implements DocumentationRenderer {
1922
DocumentationRenderResult render(
2023
List<md.Node> nodes, {
2124
required bool processFullDocs,
25+
required bool sanitizeHtml,
2226
}) {
2327
if (nodes.isEmpty) {
2428
return DocumentationRenderResult.empty;
2529
}
30+
2631
var rawHtml = md.HtmlRenderer().render(nodes);
27-
var asHtmlDocument = parse(rawHtml);
28-
for (var s in asHtmlDocument.querySelectorAll('script')) {
29-
s.remove();
30-
}
31-
for (var pre in asHtmlDocument.querySelectorAll('pre')) {
32+
var asHtmlFragment = parseFragment(rawHtml);
33+
34+
for (var pre in asHtmlFragment.querySelectorAll('pre')) {
3235
if (pre.children.length > 1 && pre.children.first.localName != 'code') {
3336
continue;
3437
}
@@ -43,15 +46,21 @@ class DocumentationRendererHtml implements DocumentationRenderer {
4346
// Assume the user intended Dart if there are no other classes present.
4447
if (!specifiesLanguage) pre.classes.add('language-dart');
4548
}
49+
50+
if (sanitizeHtml) {
51+
_sanitize(asHtmlFragment);
52+
}
53+
4654
var asHtml = '';
4755

4856
if (processFullDocs) {
4957
// `trim` fixes an issue with line ending differences between Mac and
5058
// Windows.
51-
asHtml = (asHtmlDocument.body?.innerHtml ?? '').trim();
59+
asHtml = asHtmlFragment.outerHtml.trim();
5260
}
53-
var children = asHtmlDocument.body?.children ?? [];
54-
var asOneLiner = children.isEmpty ? '' : children.first.innerHtml;
61+
var asOneLiner = asHtmlFragment.children.isEmpty
62+
? ''
63+
: asHtmlFragment.children.first.innerHtml;
5564

5665
return DocumentationRenderResult(asHtml: asHtml, asOneLiner: asOneLiner);
5766
}
@@ -60,9 +69,259 @@ class DocumentationRendererHtml implements DocumentationRenderer {
6069
class DocumentationRenderResult {
6170
static const empty = DocumentationRenderResult(asHtml: '', asOneLiner: '');
6271

63-
final String /*?*/ asHtml;
72+
final String asHtml;
6473
final String asOneLiner;
6574

6675
const DocumentationRenderResult(
6776
{required this.asHtml, required this.asOneLiner});
6877
}
78+
79+
bool _allowClassName(String className) =>
80+
className == 'deprecated' || className.startsWith('language-');
81+
82+
Iterable<String> _addLinkRel(String uri) {
83+
final u = Uri.tryParse(uri);
84+
if (u != null && u.host.isNotEmpty) {
85+
// TODO(jonasfj): Consider allowing non-ugc links for trusted sites.
86+
return ['ugc'];
87+
}
88+
return [];
89+
}
90+
91+
void _sanitize(dom.Node node) {
92+
if (node is dom.Element) {
93+
final tagName = node.localName!.toUpperCase();
94+
if (!_allowedElements.contains(tagName)) {
95+
node.remove();
96+
return;
97+
}
98+
node.attributes.removeWhere((k, v) {
99+
final attrName = k.toString();
100+
if (attrName == 'class') {
101+
node.classes.removeWhere((cn) => !_allowClassName(cn));
102+
return node.classes.isEmpty;
103+
}
104+
return !_isAttributeAllowed(tagName, attrName, v);
105+
});
106+
if (tagName == 'A') {
107+
final href = node.attributes['href'];
108+
if (href != null) {
109+
final rels = _addLinkRel(href);
110+
if (rels.isNotEmpty) {
111+
node.attributes['rel'] = rels.join(' ');
112+
}
113+
}
114+
}
115+
}
116+
if (node.hasChildNodes()) {
117+
// doing it in reverse order, because we could otherwise skip one, when a
118+
// node is removed...
119+
for (var i = node.nodes.length - 1; i >= 0; i--) {
120+
_sanitize(node.nodes[i]);
121+
}
122+
}
123+
}
124+
125+
bool _isAttributeAllowed(String tagName, String attrName, String value) {
126+
if (_alwaysAllowedAttributes.contains(attrName)) return true;
127+
128+
// Special validators for special attributes on special tags (href/src/cite)
129+
final attributeValidators = _elementAttributeValidators[tagName];
130+
if (attributeValidators == null) {
131+
return false;
132+
}
133+
134+
final validator = attributeValidators[attrName];
135+
if (validator == null) {
136+
return false;
137+
}
138+
139+
return validator(value);
140+
}
141+
142+
// Inspired by the set of HTML tags allowed in GFM.
143+
final _allowedElements = <String>{
144+
'H1',
145+
'H2',
146+
'H3',
147+
'H4',
148+
'H5',
149+
'H6',
150+
'H7',
151+
'H8',
152+
'BR',
153+
'B',
154+
'I',
155+
'STRONG',
156+
'EM',
157+
'A',
158+
'PRE',
159+
'CODE',
160+
'IMG',
161+
'TT',
162+
'DIV',
163+
'INS',
164+
'DEL',
165+
'SUP',
166+
'SUB',
167+
'P',
168+
'OL',
169+
'UL',
170+
'TABLE',
171+
'THEAD',
172+
'TBODY',
173+
'TFOOT',
174+
'BLOCKQUOTE',
175+
'DL',
176+
'DT',
177+
'DD',
178+
'KBD',
179+
'Q',
180+
'SAMP',
181+
'VAR',
182+
'HR',
183+
'RUBY',
184+
'RT',
185+
'RP',
186+
'LI',
187+
'TR',
188+
'TD',
189+
'TH',
190+
'S',
191+
'STRIKE',
192+
'SUMMARY',
193+
'DETAILS',
194+
'CAPTION',
195+
'FIGURE',
196+
'FIGCAPTION',
197+
'ABBR',
198+
'BDO',
199+
'CITE',
200+
'DFN',
201+
'MARK',
202+
'SMALL',
203+
'SPAN',
204+
'TIME',
205+
'WBR',
206+
};
207+
208+
// Inspired by the set of HTML attributes allowed in GFM.
209+
final _alwaysAllowedAttributes = <String>{
210+
'abbr',
211+
'accept',
212+
'accept-charset',
213+
'accesskey',
214+
'action',
215+
'align',
216+
'alt',
217+
'aria-describedby',
218+
'aria-hidden',
219+
'aria-label',
220+
'aria-labelledby',
221+
'axis',
222+
'border',
223+
'cellpadding',
224+
'cellspacing',
225+
'char',
226+
'charoff',
227+
'charset',
228+
'checked',
229+
'clear',
230+
'cols',
231+
'colspan',
232+
'color',
233+
'compact',
234+
'coords',
235+
'datetime',
236+
'dir',
237+
'disabled',
238+
'enctype',
239+
'for',
240+
'frame',
241+
'headers',
242+
'height',
243+
'hreflang',
244+
'hspace',
245+
'ismap',
246+
'label',
247+
'lang',
248+
'maxlength',
249+
'media',
250+
'method',
251+
'multiple',
252+
'name',
253+
'nohref',
254+
'noshade',
255+
'nowrap',
256+
'open',
257+
'prompt',
258+
'readonly',
259+
'rel',
260+
'rev',
261+
'rows',
262+
'rowspan',
263+
'rules',
264+
'scope',
265+
'selected',
266+
'shape',
267+
'size',
268+
'span',
269+
'start',
270+
'summary',
271+
'tabindex',
272+
'target',
273+
'title',
274+
'type',
275+
'usemap',
276+
'valign',
277+
'value',
278+
'vspace',
279+
'width',
280+
'itemprop',
281+
};
282+
283+
bool _alwaysAllowed(String _) => true;
284+
285+
bool _validLink(String url) {
286+
try {
287+
final uri = Uri.parse(url);
288+
return uri.isScheme('https') ||
289+
uri.isScheme('http') ||
290+
uri.isScheme('mailto') ||
291+
!uri.hasScheme;
292+
} on FormatException {
293+
return false;
294+
}
295+
}
296+
297+
bool _validUrl(String url) {
298+
try {
299+
final uri = Uri.parse(url);
300+
return uri.isScheme('https') || uri.isScheme('http') || !uri.hasScheme;
301+
} on FormatException {
302+
return false;
303+
}
304+
}
305+
306+
final _citeAttributeValidator = <String, bool Function(String)>{
307+
'cite': _validUrl,
308+
};
309+
310+
final _elementAttributeValidators =
311+
<String, Map<String, bool Function(String)>>{
312+
'A': {
313+
'href': _validLink,
314+
},
315+
'IMG': {
316+
'src': _validUrl,
317+
'longdesc': _validUrl,
318+
},
319+
'DIV': {
320+
'itemscope': _alwaysAllowed,
321+
'itemtype': _alwaysAllowed,
322+
},
323+
'BLOCKQUOTE': _citeAttributeValidator,
324+
'DEL': _citeAttributeValidator,
325+
'INS': _citeAttributeValidator,
326+
'Q': _citeAttributeValidator,
327+
};

lib/src/version.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// Generated code. Do not modify.
2-
const packageVersion = '4.0.0';
2+
const packageVersion = '4.1.0-dev';

pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: dartdoc
2-
# Run `grind build` after updating.
3-
version: 4.0.0
2+
# Run `dart run grinder build` after updating.
3+
version: 4.1.0-dev
44
description: A non-interactive HTML documentation generator for Dart source code.
55
homepage: https://github.com/dart-lang/dartdoc
66
environment:

0 commit comments

Comments
 (0)