Added and migrated html_builder
This commit is contained in:
parent
b091b435e4
commit
2a69ecf91b
34 changed files with 3379 additions and 16 deletions
|
@ -24,7 +24,9 @@
|
|||
* Migrated angel_jael to 4.0.0 (1/1 test passed)
|
||||
* Migrated pub_sub to 4.0.0 (16/16 tests passed)
|
||||
* Migrated production to 3.0.0 (0/0 tests passed)
|
||||
* Added html_builder and migrated to 2.0.0 (16/16 tests passed)
|
||||
* Updated hot to 3.0.0 (in progress)
|
||||
* Added range_header and migrated to 2.0.0 (16/16 tests passed)
|
||||
* Updated static to 3.0.0 (in progress)
|
||||
* Update basic-sdk-2.12.x boilerplate (in progress)
|
||||
|
||||
|
|
|
@ -14,8 +14,8 @@ import 'package:html_builder/elements.dart';
|
|||
import 'package:html_builder/html_builder.dart';
|
||||
import 'package:io/ansi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:vm_service_lib/vm_service_lib.dart' as vm;
|
||||
import 'package:vm_service_lib/vm_service_lib_io.dart' as vm;
|
||||
import 'package:vm_service/vm_service.dart' as vm;
|
||||
import 'package:vm_service/vm_service_io.dart' as vm;
|
||||
import 'package:watcher/watcher.dart';
|
||||
|
||||
/// A utility class that watches the filesystem for changes, and starts new instances of an Angel server.
|
||||
|
@ -374,7 +374,7 @@ class HotReloader {
|
|||
if (hot) {
|
||||
var report = await _client.reloadSources(_mainIsolate.id);
|
||||
|
||||
if (!report.success) {
|
||||
if (report.success != null) {
|
||||
_logWarning(
|
||||
'Hot reload failed - perhaps some sources have not been generated yet.');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: angel_hot
|
||||
description: Supports hot reloading/hot code push of Angel servers on file changes.
|
||||
version: 3.0.0
|
||||
version: 4.0.0
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/hot
|
||||
publish_to: none
|
||||
|
@ -10,19 +10,19 @@ dependencies:
|
|||
angel_framework:
|
||||
git:
|
||||
url: https://github.com/dukefirehawk/angel.git
|
||||
ref: sdk-2.12.x
|
||||
ref: sdk-2.12.x_nnbd
|
||||
path: packages/framework
|
||||
angel_websocket: #^2.0.0-alpha
|
||||
angel_websocket:
|
||||
git:
|
||||
url: https://github.com/dukefirehawk/angel.git
|
||||
ref: sdk-2.12.x
|
||||
ref: sdk-2.12.x_nnbd
|
||||
path: packages/websocket
|
||||
charcode: ^1.0.0
|
||||
glob: ^2.0.0
|
||||
html_builder: ^1.0.0
|
||||
io: ^0.3.5
|
||||
path: ^1.0.0
|
||||
vm_service_lib: ^3.22.2+1
|
||||
vm_service: ^5.5.0
|
||||
watcher: ^1.0.0
|
||||
dev_dependencies:
|
||||
http: ^0.13.0
|
||||
|
|
9
packages/html_builder/CHANGELOG.md
Normal file
9
packages/html_builder/CHANGELOG.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# 1.0.4
|
||||
* Added `rebuild`, `rebuildRecursive`, and `NodeBuilder`.
|
||||
|
||||
# 1.0.3
|
||||
* Dart 2 ready!
|
||||
|
||||
# 1.0.2
|
||||
Changed `h` and the `Node` constructor to take `Iterable`s of children,
|
||||
instead of just `List`s.
|
21
packages/html_builder/LICENSE
Normal file
21
packages/html_builder/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 Tobe O
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
108
packages/html_builder/README.md
Normal file
108
packages/html_builder/README.md
Normal file
|
@ -0,0 +1,108 @@
|
|||
# html_builder
|
||||
[![Pub](https://img.shields.io/pub/v/html_builder.svg)](https://pub.dartlang.org/packages/html_builder)
|
||||
[![build status](https://travis-ci.org/thosakwe/html_builder.svg)](https://travis-ci.org/thosakwe/html_builder)
|
||||
|
||||
Build HTML AST's and render them to HTML.
|
||||
|
||||
This can be used as an internal DSL, i.e. for a templating engine.
|
||||
|
||||
# Installation
|
||||
In your `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
html_builder: ^1.0.0
|
||||
```
|
||||
|
||||
# Usage
|
||||
```dart
|
||||
import 'package:html_builder/html_builder.dart';
|
||||
|
||||
main() {
|
||||
// Akin to React.createElement(...);
|
||||
var $el = h('my-element', p: {}, c: []);
|
||||
|
||||
|
||||
// Attributes can be plain Strings.
|
||||
h('foo', p: {
|
||||
'bar': 'baz'
|
||||
});
|
||||
|
||||
i // Null attributes do not appear.
|
||||
h('foo', p: {
|
||||
'does-not-appear': null
|
||||
});
|
||||
|
||||
// If an attribute is a bool, then it will only appear if its value is true.
|
||||
h('foo', p: {
|
||||
'appears': true,
|
||||
'does-not-appear': false
|
||||
});
|
||||
:
|
||||
// Or, a String or Map.
|
||||
h('foo', p: {
|
||||
'style': 'background-color: white; color: red;'
|
||||
});
|
||||
|
||||
h('foo', p: {
|
||||
'style': {
|
||||
'background-color': 'white',
|
||||
'color': 'red'
|
||||
/ }
|
||||
});
|
||||
|
||||
// Or, a String or Iterable.
|
||||
h('foo', p: {
|
||||
'class': 'a b'
|
||||
});
|
||||
|
||||
h('foo', p: {
|
||||
'class': ['a', 'b']
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Standard HTML5 elements:
|
||||
```dart
|
||||
import 'package:html_builder/elements.dart';
|
||||
|
||||
main() {
|
||||
var $dom = html(lang: 'en', c: [
|
||||
head(c: [
|
||||
title(c: [text('Hello, world!')])
|
||||
]),
|
||||
body(c: [
|
||||
h1(c: [text('Hello, world!')]),
|
||||
p(c: [text('Ok')])
|
||||
])
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Rendering to HTML:
|
||||
```dart
|
||||
String html = new StringRenderer().render($dom);
|
||||
```
|
||||
|
||||
Example with the [Angel](https://github.com/angel-dart/angel) server-side framework,
|
||||
which has [dedicated html_builder support](https://github.com/angel-dart/html):
|
||||
|
||||
```dart
|
||||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:html_builder/elements.dart';
|
||||
|
||||
configureViews(Angel app) async {
|
||||
app.get('/foo/:id', (req, res) async {
|
||||
var foo = await app.service('foo').read(req.params['id']);
|
||||
return html(c: [
|
||||
head(c: [
|
||||
title(c: [text(foo.name)])
|
||||
]),
|
||||
body(c: [
|
||||
h1(c: [text(foo.name)])
|
||||
])
|
||||
]);
|
||||
});
|
||||
}
|
||||
```
|
3
packages/html_builder/analysis_options.yaml
Normal file
3
packages/html_builder/analysis_options.yaml
Normal file
|
@ -0,0 +1,3 @@
|
|||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
2041
packages/html_builder/lib/elements.dart
Normal file
2041
packages/html_builder/lib/elements.dart
Normal file
File diff suppressed because it is too large
Load diff
4
packages/html_builder/lib/html_builder.dart
Normal file
4
packages/html_builder/lib/html_builder.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
export 'src/mutations.dart';
|
||||
export 'src/node.dart';
|
||||
export 'src/node_builder.dart';
|
||||
export 'src/renderer.dart';
|
20
packages/html_builder/lib/src/mutations.dart
Normal file
20
packages/html_builder/lib/src/mutations.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import 'node.dart';
|
||||
import 'node_builder.dart';
|
||||
|
||||
/// Returns a function that rebuilds an arbitrary [Node] by applying the [transform] to it.
|
||||
Node Function(Node) rebuild(NodeBuilder Function(NodeBuilder) transform,
|
||||
{bool selfClosing: false}) {
|
||||
return (node) =>
|
||||
transform(NodeBuilder.from(node)).build(selfClosing: selfClosing);
|
||||
}
|
||||
|
||||
/// Applies [f] to all children of this node, recursively.
|
||||
///
|
||||
/// Use this alongside [rebuild].
|
||||
Node Function(Node) rebuildRecursive(Node Function(Node) f) {
|
||||
Node _build(Node node) {
|
||||
return NodeBuilder.from(f(node)).mapChildren(_build).build();
|
||||
}
|
||||
|
||||
return _build;
|
||||
}
|
56
packages/html_builder/lib/src/node.dart
Normal file
56
packages/html_builder/lib/src/node.dart
Normal file
|
@ -0,0 +1,56 @@
|
|||
import 'package:collection/collection.dart';
|
||||
|
||||
/// Shorthand function to generate a new [Node].
|
||||
Node h(String tagName,
|
||||
[Map<String, dynamic> attributes = const {},
|
||||
Iterable<Node> children = const []]) =>
|
||||
Node(tagName, attributes, children);
|
||||
|
||||
/// Represents an HTML node.
|
||||
class Node {
|
||||
final String tagName;
|
||||
final Map<String, dynamic> attributes = {};
|
||||
final List<Node> children = [];
|
||||
|
||||
Node(this.tagName,
|
||||
[Map<String, dynamic> attributes = const {},
|
||||
Iterable<Node> children = const []]) {
|
||||
this..attributes.addAll(attributes)..children.addAll(children);
|
||||
}
|
||||
|
||||
Node._selfClosing(this.tagName,
|
||||
[Map<String, dynamic> attributes = const {}]) {
|
||||
this..attributes.addAll(attributes);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
return other is Node &&
|
||||
other.tagName == tagName &&
|
||||
const ListEquality<Node>().equals(other.children, children) &&
|
||||
const MapEquality<String, dynamic>()
|
||||
.equals(other.attributes, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a self-closing tag, i.e. `<br>`.
|
||||
class SelfClosingNode extends Node {
|
||||
final String tagName;
|
||||
final Map<String, dynamic> attributes = {};
|
||||
|
||||
@override
|
||||
List<Node> get children => List<Node>.unmodifiable([]);
|
||||
|
||||
SelfClosingNode(this.tagName, [Map<String, dynamic> attributes = const {}])
|
||||
: super._selfClosing(tagName, attributes);
|
||||
}
|
||||
|
||||
/// Represents a text node.
|
||||
class TextNode extends Node {
|
||||
final String text;
|
||||
|
||||
TextNode(this.text) : super(':text');
|
||||
|
||||
@override
|
||||
bool operator ==(other) => other is TextNode && other.text == text;
|
||||
}
|
106
packages/html_builder/lib/src/node_builder.dart
Normal file
106
packages/html_builder/lib/src/node_builder.dart
Normal file
|
@ -0,0 +1,106 @@
|
|||
import 'node.dart';
|
||||
|
||||
/// Helper class to build nodes.
|
||||
class NodeBuilder {
|
||||
final String tagName;
|
||||
final Map<String, dynamic> attributes;
|
||||
final Iterable<Node> children;
|
||||
Node? _existing;
|
||||
|
||||
NodeBuilder(this.tagName,
|
||||
{this.attributes: const {}, this.children: const []});
|
||||
|
||||
/// Creates a [NodeBuilder] that just spits out an already-existing [Node].
|
||||
factory NodeBuilder.existing(Node existingNode) =>
|
||||
NodeBuilder(existingNode.tagName).._existing = existingNode;
|
||||
|
||||
factory NodeBuilder.from(Node node) => NodeBuilder(node.tagName,
|
||||
attributes: Map<String, dynamic>.from(node.attributes),
|
||||
children: List<Node>.from(node.children));
|
||||
|
||||
/// Builds the node.
|
||||
Node build({bool selfClosing: false}) =>
|
||||
_existing ??
|
||||
(selfClosing
|
||||
? SelfClosingNode(tagName, attributes)
|
||||
: Node(tagName, attributes, children));
|
||||
|
||||
/// Produce a modified copy of this builder.
|
||||
NodeBuilder change(
|
||||
{String? tagName,
|
||||
Map<String, dynamic>? attributes,
|
||||
Iterable<Node>? children}) {
|
||||
return NodeBuilder(tagName ?? this.tagName,
|
||||
attributes: attributes ?? this.attributes,
|
||||
children: children ?? this.children);
|
||||
}
|
||||
|
||||
NodeBuilder changeTagName(String tagName) => change(tagName: tagName);
|
||||
|
||||
NodeBuilder changeAttributes(Map<String, dynamic> attributes) =>
|
||||
change(attributes: attributes);
|
||||
|
||||
NodeBuilder changeChildren(Iterable<Node> children) =>
|
||||
change(children: children);
|
||||
|
||||
NodeBuilder changeAttributesMapped(
|
||||
Map<String, dynamic> Function(Map<String, dynamic>) f) {
|
||||
var map = Map<String, dynamic>.from(attributes);
|
||||
return changeAttributes(f(map));
|
||||
}
|
||||
|
||||
NodeBuilder changeChildrenMapped(Iterable<Node> Function(List<Node>) f) {
|
||||
var list = List<Node>.from(children);
|
||||
return changeChildren(f(list));
|
||||
}
|
||||
|
||||
NodeBuilder mapChildren(Node Function(Node) f) =>
|
||||
changeChildrenMapped((list) => list.map(f));
|
||||
|
||||
NodeBuilder mapAttributes(
|
||||
MapEntry<String, dynamic> Function(String, dynamic) f) =>
|
||||
changeAttributesMapped((map) => map.map(f));
|
||||
|
||||
NodeBuilder setAttribute(String name, dynamic value) =>
|
||||
changeAttributesMapped((map) => map..[name] = value);
|
||||
|
||||
NodeBuilder addChild(Node child) =>
|
||||
changeChildrenMapped((list) => list..add(child));
|
||||
|
||||
NodeBuilder removeChild(Node child) =>
|
||||
changeChildrenMapped((list) => list..remove(child));
|
||||
|
||||
NodeBuilder removeAttribute(String name) =>
|
||||
changeAttributesMapped((map) => map..remove(name));
|
||||
|
||||
NodeBuilder setId(String id) => setAttribute('id', id);
|
||||
|
||||
NodeBuilder setClassName(String className) =>
|
||||
setAttribute('class', className);
|
||||
|
||||
NodeBuilder setClasses(Iterable<String> classes) =>
|
||||
setClassName(classes.join(' '));
|
||||
|
||||
NodeBuilder setClassesMapped(Iterable<String> Function(List<String>) f) {
|
||||
var clazz = attributes['class'];
|
||||
var classes = <String>[];
|
||||
|
||||
if (clazz is String)
|
||||
classes.addAll(clazz.split(' '));
|
||||
else if (clazz is Iterable) classes.addAll(clazz.map((s) => s.toString()));
|
||||
|
||||
return setClasses(f(classes));
|
||||
}
|
||||
|
||||
NodeBuilder addClass(String className) => setClassesMapped(
|
||||
(classes) => classes.contains(className) ? classes : classes
|
||||
..add(className));
|
||||
|
||||
NodeBuilder removeClass(String className) =>
|
||||
setClassesMapped((classes) => classes..remove(className));
|
||||
|
||||
NodeBuilder toggleClass(String className) =>
|
||||
setClassesMapped((classes) => classes.contains(className)
|
||||
? (classes..remove(className))
|
||||
: (classes..add(className)));
|
||||
}
|
134
packages/html_builder/lib/src/renderer.dart
Normal file
134
packages/html_builder/lib/src/renderer.dart
Normal file
|
@ -0,0 +1,134 @@
|
|||
import 'node.dart';
|
||||
|
||||
/// An object that can render a DOM tree into another representation, i.e. a `String`.
|
||||
abstract class Renderer<T> {
|
||||
/// Renders a DOM tree into another representation.
|
||||
T render(Node rootNode);
|
||||
}
|
||||
|
||||
/// Renders a DOM tree into a HTML string.
|
||||
abstract class StringRenderer implements Renderer<String> {
|
||||
/// Initializes a new [StringRenderer].
|
||||
///
|
||||
/// If [html5] is not `false` (default: `true`), then self-closing elements will be rendered with a slash before the last angle bracket, ex. `<br />`.
|
||||
/// If [pretty] is `true` (default), then [whitespace] (default: `' '`) will be inserted between nodes.
|
||||
/// You can also provide a [doctype] (default: `html`).
|
||||
factory StringRenderer(
|
||||
{bool html5: true,
|
||||
bool pretty: true,
|
||||
String doctype: 'html',
|
||||
String whitespace: ' '}) =>
|
||||
pretty == true
|
||||
? _PrettyStringRendererImpl(
|
||||
html5: html5 != false, doctype: doctype, whitespace: whitespace)
|
||||
: _StringRendererImpl(html5: html5 != false, doctype: doctype);
|
||||
}
|
||||
|
||||
class _StringRendererImpl implements StringRenderer {
|
||||
final String? doctype;
|
||||
final bool? html5;
|
||||
|
||||
_StringRendererImpl({this.html5, this.doctype});
|
||||
|
||||
void _renderInto(Node node, StringBuffer buf) {
|
||||
if (node is TextNode) {
|
||||
buf.write(node.text);
|
||||
} else {
|
||||
buf.write('<${node.tagName}');
|
||||
|
||||
node.attributes.forEach((k, v) {
|
||||
if (v == true) {
|
||||
buf.write(' $k');
|
||||
} else if (v == false || v == null) {
|
||||
// Ignore
|
||||
} else if (v is Iterable) {
|
||||
var val = v.join(' ').replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
} else if (v is Map) {
|
||||
var val = v.keys
|
||||
.fold<String>('', (out, k) => out += '$k: ${v[k]};')
|
||||
.replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
} else {
|
||||
var val = v.toString().replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
}
|
||||
});
|
||||
|
||||
if (node is SelfClosingNode) {
|
||||
buf.write((html5 != false) ? '>' : '/>');
|
||||
} else {
|
||||
buf.write('>');
|
||||
node.children.forEach((child) => _renderInto(child, buf));
|
||||
buf.write('</${node.tagName}>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String render(Node rootNode) {
|
||||
var buf = StringBuffer();
|
||||
if (doctype?.isNotEmpty == true) buf.write('<!DOCTYPE $doctype>');
|
||||
_renderInto(rootNode, buf);
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class _PrettyStringRendererImpl implements StringRenderer {
|
||||
final bool? html5;
|
||||
final String? doctype, whitespace;
|
||||
|
||||
_PrettyStringRendererImpl({this.html5, this.whitespace, this.doctype});
|
||||
|
||||
void _applyTabs(int tabs, StringBuffer buf) {
|
||||
for (int i = 0; i < tabs; i++) buf.write(whitespace ?? ' ');
|
||||
}
|
||||
|
||||
void _renderInto(int tabs, Node node, StringBuffer buf) {
|
||||
if (tabs > 0) buf.writeln();
|
||||
_applyTabs(tabs, buf);
|
||||
|
||||
if (node is TextNode) {
|
||||
buf.write(node.text);
|
||||
} else {
|
||||
buf.write('<${node.tagName}');
|
||||
|
||||
node.attributes.forEach((k, v) {
|
||||
if (v == true) {
|
||||
buf.write(' $k');
|
||||
} else if (v == false || v == null) {
|
||||
// Ignore
|
||||
} else if (v is Iterable) {
|
||||
var val = v.join(' ').replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
} else if (v is Map) {
|
||||
var val = v.keys
|
||||
.fold<String>('', (out, k) => out += '$k: ${v[k]};')
|
||||
.replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
} else {
|
||||
var val = v.toString().replaceAll('"', '\\"');
|
||||
buf.write(' $k="$val"');
|
||||
}
|
||||
});
|
||||
|
||||
if (node is SelfClosingNode) {
|
||||
buf.write((html5 != false) ? '>' : '/>');
|
||||
} else {
|
||||
buf.write('>');
|
||||
node.children.forEach((child) => _renderInto(tabs + 1, child, buf));
|
||||
buf.writeln();
|
||||
_applyTabs(tabs, buf);
|
||||
buf.write('</${node.tagName}>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String render(Node rootNode) {
|
||||
var buf = StringBuffer();
|
||||
if (doctype?.isNotEmpty == true) buf.writeln('<!DOCTYPE $doctype>');
|
||||
_renderInto(0, rootNode, buf);
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
10
packages/html_builder/pubspec.yaml
Normal file
10
packages/html_builder/pubspec.yaml
Normal file
|
@ -0,0 +1,10 @@
|
|||
name: html_builder
|
||||
description: Build HTML AST's and render them to HTML. This can be used as an internal DSL, i.e. for a templating engine.
|
||||
version: 2.0.0
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/thosakwe/html_builder
|
||||
environment:
|
||||
sdk: '>=2.12.0 <3.0.0'
|
||||
dev_dependencies:
|
||||
html: ^0.15.0
|
||||
test: ^1.17.3
|
34
packages/html_builder/test/render_test.dart
Normal file
34
packages/html_builder/test/render_test.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
import 'package:html/parser.dart' as html5;
|
||||
import 'package:html_builder/elements.dart';
|
||||
import 'package:html_builder/html_builder.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
test('pretty', () {
|
||||
var $dom = html(
|
||||
lang: 'en',
|
||||
c: [
|
||||
head(c: [
|
||||
title(c: [text('Hello, world!')])
|
||||
]),
|
||||
body(
|
||||
p: {'unresolved': true},
|
||||
c: [
|
||||
h1(c: [text('Hello, world!')]),
|
||||
br(),
|
||||
hr(),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
var rendered = StringRenderer().render($dom);
|
||||
print(rendered);
|
||||
|
||||
var $parsed = html5.parse(rendered);
|
||||
var $title = $parsed.querySelector('title')!;
|
||||
expect($title.text.trim(), 'Hello, world!');
|
||||
var $h1 = $parsed.querySelector('h1')!;
|
||||
expect($h1.text.trim(), 'Hello, world!');
|
||||
});
|
||||
}
|
12
packages/range_header/CHANGELOG.md
Normal file
12
packages/range_header/CHANGELOG.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# 2.0.2
|
||||
* Fix bug in `toContentRange` that printed invalid indices.
|
||||
* Fold header items by default.
|
||||
|
||||
# 2.0.1
|
||||
* Adjust `RangeHeaderTransformer` to properly print the content range of each item,
|
||||
when multiple are present.
|
||||
|
||||
# 2.0.0
|
||||
* Dart 2 update.
|
||||
* Add `RangeHeaderTransformer`.
|
||||
* Overall restructuring/refactoring.
|
21
packages/range_header/LICENSE
Normal file
21
packages/range_header/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 Tobe O
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
36
packages/range_header/README.md
Normal file
36
packages/range_header/README.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
# range_header
|
||||
|
||||
[![Pub](https://img.shields.io/pub/v/range_header.svg)](https://pub.dartlang.org/packages/range_header)
|
||||
[![build status](https://travis-ci.org/thosakwe/range_header.svg)](https://travis-ci.org/thosakwe/range_header)
|
||||
|
||||
Range header parser for Dart.
|
||||
|
||||
# Installation
|
||||
In your `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
range_header: ^2.0.0
|
||||
```
|
||||
|
||||
# Usage
|
||||
|
||||
```dart
|
||||
handleRequest(HttpRequest request) async {
|
||||
// Parse the header
|
||||
var header = new RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader));
|
||||
|
||||
// Optimize/canonicalize it
|
||||
var items = RangeHeader.foldItems(header.items);
|
||||
header = new RangeHeader(items);
|
||||
|
||||
// Get info
|
||||
header.items;
|
||||
header.rangeUnit;
|
||||
print(header.items[0].toContentRange(fileSize));
|
||||
|
||||
// Serve the file
|
||||
var transformer = new RangeHeaderTransformer(header);
|
||||
await file.openRead().transform(transformer).pipe(request.response);
|
||||
}
|
||||
```
|
3
packages/range_header/analysis_options.yaml
Normal file
3
packages/range_header/analysis_options.yaml
Normal file
|
@ -0,0 +1,3 @@
|
|||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
28
packages/range_header/example/main.dart
Normal file
28
packages/range_header/example/main.dart
Normal file
|
@ -0,0 +1,28 @@
|
|||
import 'dart:io';
|
||||
import 'package:range_header/range_header.dart';
|
||||
|
||||
var file = new File('some_video.mp4');
|
||||
|
||||
handleRequest(HttpRequest request) async {
|
||||
// Parse the header
|
||||
var header =
|
||||
new RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader));
|
||||
|
||||
// Optimize/canonicalize it
|
||||
var items = RangeHeader.foldItems(header.items);
|
||||
header = new RangeHeader(items);
|
||||
|
||||
// Get info
|
||||
header.items;
|
||||
header.rangeUnit;
|
||||
header.items.forEach((item) => item.toContentRange(400));
|
||||
|
||||
// Serve the file
|
||||
var transformer =
|
||||
new RangeHeaderTransformer(header, 'video/mp4', await file.length());
|
||||
await file
|
||||
.openRead()
|
||||
.cast<List<int>>()
|
||||
.transform(transformer)
|
||||
.pipe(request.response);
|
||||
}
|
108
packages/range_header/example/server.dart
Normal file
108
packages/range_header/example/server.dart
Normal file
|
@ -0,0 +1,108 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show HttpHeaders, HttpStatus;
|
||||
import 'dart:typed_data';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:range_header/range_header.dart';
|
||||
|
||||
main() async {
|
||||
var app = new Angel();
|
||||
var http = new AngelHttp(app);
|
||||
var fs = const LocalFileSystem();
|
||||
var vDir = new _RangingVirtualDirectory(app, fs.currentDirectory);
|
||||
app.logger = new Logger('range_header')
|
||||
..onRecord.listen((rec) {
|
||||
print(rec);
|
||||
if (rec.error != null) print(rec.error);
|
||||
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||
});
|
||||
app.mimeTypeResolver
|
||||
..addExtension('dart', 'text/dart')
|
||||
..addExtension('lock', 'text/plain')
|
||||
..addExtension('md', 'text/plain')
|
||||
..addExtension('packages', 'text/plain')
|
||||
..addExtension('yaml', 'text/plain')
|
||||
..addExtension('yml', 'text/plain');
|
||||
app.fallback(vDir.handleRequest);
|
||||
app.fallback((req, res) => throw new AngelHttpException.notFound());
|
||||
await http.startServer('127.0.0.1', 3000);
|
||||
print('Listening at ${http.uri}');
|
||||
}
|
||||
|
||||
class _RangingVirtualDirectory extends VirtualDirectory {
|
||||
_RangingVirtualDirectory(Angel app, Directory source)
|
||||
: super(app, source.fileSystem,
|
||||
source: source, allowDirectoryListing: true);
|
||||
|
||||
@override
|
||||
Future<bool> serveFile(
|
||||
File file, FileStat stat, RequestContext req, ResponseContext res) async {
|
||||
res.headers[HttpHeaders.acceptRangesHeader] = 'bytes';
|
||||
|
||||
if (req.headers.value(HttpHeaders.rangeHeader)?.startsWith('bytes') ==
|
||||
true) {
|
||||
var header =
|
||||
new RangeHeader.parse(req.headers.value(HttpHeaders.rangeHeader));
|
||||
header = new RangeHeader(RangeHeader.foldItems(header.items));
|
||||
|
||||
if (header.items.length == 1) {
|
||||
var item = header.items[0];
|
||||
Stream<Uint8List> stream;
|
||||
int len = 0, total = await file.length();
|
||||
|
||||
if (item.start == -1) {
|
||||
if (item.end == -1) {
|
||||
len = total;
|
||||
stream = file.openRead();
|
||||
} else {
|
||||
len = item.end + 1;
|
||||
stream = file.openRead(0, item.end + 1);
|
||||
}
|
||||
} else {
|
||||
if (item.end == -1) {
|
||||
len = total - item.start;
|
||||
stream = file.openRead(item.start);
|
||||
} else {
|
||||
len = item.end - item.start + 1;
|
||||
stream = file.openRead(item.start, item.end + 1);
|
||||
}
|
||||
}
|
||||
|
||||
res.contentType = new MediaType.parse(
|
||||
app.mimeTypeResolver.lookup(file.path) ??
|
||||
'application/octet-stream');
|
||||
res.statusCode = HttpStatus.partialContent;
|
||||
res.headers[HttpHeaders.contentLengthHeader] = len.toString();
|
||||
res.headers[HttpHeaders.contentRangeHeader] =
|
||||
'bytes ' + item.toContentRange(total);
|
||||
await stream.cast<List<int>>().pipe(res);
|
||||
return false;
|
||||
} else {
|
||||
var totalFileSize = await file.length();
|
||||
var transformer = new RangeHeaderTransformer(
|
||||
header,
|
||||
app.mimeTypeResolver.lookup(file.path) ??
|
||||
'application/octet-stream',
|
||||
await file.length());
|
||||
res.statusCode = HttpStatus.partialContent;
|
||||
res.headers[HttpHeaders.contentLengthHeader] =
|
||||
transformer.computeContentLength(totalFileSize).toString();
|
||||
res.contentType = new MediaType(
|
||||
'multipart', 'byteranges', {'boundary': transformer.boundary});
|
||||
await file
|
||||
.openRead()
|
||||
.cast<List<int>>()
|
||||
.transform(transformer)
|
||||
.pipe(res);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return await super.serveFile(file, stat, req, res);
|
||||
}
|
||||
}
|
||||
}
|
4
packages/range_header/lib/range_header.dart
Normal file
4
packages/range_header/lib/range_header.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
export 'src/converter.dart';
|
||||
export 'src/exception.dart';
|
||||
export 'src/range_header.dart';
|
||||
export 'src/range_header_item.dart';
|
163
packages/range_header/lib/src/converter.dart
Normal file
163
packages/range_header/lib/src/converter.dart
Normal file
|
@ -0,0 +1,163 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show BytesBuilder;
|
||||
import 'dart:math';
|
||||
import 'package:async/async.dart';
|
||||
import 'package:charcode/ascii.dart';
|
||||
import 'range_header.dart';
|
||||
|
||||
/// A [StreamTransformer] that uses a parsed [RangeHeader] and transforms an input stream
|
||||
/// into one compatible with the `multipart/byte-ranges` specification.
|
||||
class RangeHeaderTransformer
|
||||
extends StreamTransformerBase<List<int>, List<int>> {
|
||||
final RangeHeader header;
|
||||
final String boundary, mimeType;
|
||||
final int totalLength;
|
||||
|
||||
RangeHeaderTransformer(this.header, this.mimeType, this.totalLength,
|
||||
{String boundary})
|
||||
: this.boundary = boundary ?? _randomString() {
|
||||
if (header == null || header.items.isEmpty) {
|
||||
throw new ArgumentError('`header` cannot be null or empty.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the content length that will be written to a response, given a stream of the given [totalFileSize].
|
||||
int computeContentLength(int totalFileSize) {
|
||||
int len = 0;
|
||||
|
||||
for (var item in header.items) {
|
||||
if (item.start == -1) {
|
||||
if (item.end == -1) {
|
||||
len += totalFileSize;
|
||||
} else {
|
||||
//len += item.end + 1;
|
||||
len += item.end + 1;
|
||||
}
|
||||
} else if (item.end == -1) {
|
||||
len += totalFileSize - item.start;
|
||||
//len += totalFileSize - item.start - 1;
|
||||
} else {
|
||||
len += item.end - item.start;
|
||||
}
|
||||
|
||||
// Take into consideration the fact that delimiters are written.
|
||||
len += utf8.encode('--$boundary\r\n').length;
|
||||
len += utf8.encode('Content-Type: $mimeType\r\n').length;
|
||||
len += utf8
|
||||
.encode(
|
||||
'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n')
|
||||
.length;
|
||||
len += 2; // CRLF
|
||||
}
|
||||
|
||||
len += utf8.encode('--$boundary--\r\n').length;
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> bind(Stream<List<int>> stream) {
|
||||
var ctrl = new StreamController<List<int>>();
|
||||
|
||||
new Future(() async {
|
||||
var index = 0;
|
||||
var enqueued = new Queue<List<int>>();
|
||||
var q = new StreamQueue(stream);
|
||||
|
||||
Future<List<int>> absorb(int length) async {
|
||||
var out = new BytesBuilder();
|
||||
|
||||
while (out.length < length) {
|
||||
var remaining = length - out.length;
|
||||
|
||||
while (out.length < length && enqueued.isNotEmpty) {
|
||||
remaining = length - out.length;
|
||||
var blob = enqueued.removeFirst();
|
||||
|
||||
if (blob.length > remaining) {
|
||||
enqueued.addFirst(blob.skip(remaining).toList());
|
||||
blob = blob.take(remaining).toList();
|
||||
}
|
||||
|
||||
out.add(blob);
|
||||
index += blob.length;
|
||||
}
|
||||
|
||||
if (out.length < length && await q.hasNext) {
|
||||
var blob = await q.next;
|
||||
remaining = length - out.length;
|
||||
|
||||
if (blob.length > remaining) {
|
||||
enqueued.addFirst(blob.skip(remaining).toList());
|
||||
blob = blob.take(remaining).toList();
|
||||
}
|
||||
|
||||
out.add(blob);
|
||||
index += blob.length;
|
||||
}
|
||||
|
||||
// If we get this far, and the stream is EMPTY, the user requested
|
||||
// too many bytes.
|
||||
if (out.length < length && enqueued.isEmpty && !(await q.hasNext)) {
|
||||
throw new StateError(
|
||||
'The range denoted is bigger than the size of the input stream.');
|
||||
}
|
||||
}
|
||||
|
||||
return out.takeBytes();
|
||||
}
|
||||
|
||||
for (var item in header.items) {
|
||||
var chunk = new BytesBuilder();
|
||||
|
||||
// Skip until we reach the start index.
|
||||
while (index < item.start) {
|
||||
var remaining = item.start - index;
|
||||
await absorb(remaining);
|
||||
}
|
||||
|
||||
// Next, absorb until we reach the end.
|
||||
if (item.end == -1) {
|
||||
while (enqueued.isNotEmpty) chunk.add(enqueued.removeFirst());
|
||||
while (await q.hasNext) chunk.add(await q.next);
|
||||
} else {
|
||||
var remaining = item.end - index;
|
||||
chunk.add(await absorb(remaining));
|
||||
}
|
||||
|
||||
// Next, write the boundary and data.
|
||||
ctrl.add(utf8.encode('--$boundary\r\n'));
|
||||
ctrl.add(utf8.encode('Content-Type: $mimeType\r\n'));
|
||||
ctrl.add(utf8.encode(
|
||||
'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n'));
|
||||
ctrl.add(chunk.takeBytes());
|
||||
ctrl.add(const [$cr, $lf]);
|
||||
|
||||
// If this range was unbounded, don't bother looping any further.
|
||||
if (item.end == -1) break;
|
||||
}
|
||||
|
||||
ctrl.add(utf8.encode('--$boundary--\r\n'));
|
||||
|
||||
ctrl.close();
|
||||
}).catchError(ctrl.addError);
|
||||
|
||||
return ctrl.stream;
|
||||
}
|
||||
}
|
||||
|
||||
var _rnd = new Random();
|
||||
String _randomString(
|
||||
{int length: 32,
|
||||
String validChars:
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'}) {
|
||||
var len = _rnd.nextInt((length - 10)) + 10;
|
||||
var buf = new StringBuffer();
|
||||
|
||||
while (buf.length < len)
|
||||
buf.writeCharCode(validChars.codeUnitAt(_rnd.nextInt(validChars.length)));
|
||||
|
||||
return buf.toString();
|
||||
}
|
8
packages/range_header/lib/src/exception.dart
Normal file
8
packages/range_header/lib/src/exception.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
class RangeHeaderParseException extends FormatException {
|
||||
final String message;
|
||||
|
||||
RangeHeaderParseException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'Range header parse exception: $message';
|
||||
}
|
143
packages/range_header/lib/src/parser.dart
Normal file
143
packages/range_header/lib/src/parser.dart
Normal file
|
@ -0,0 +1,143 @@
|
|||
import 'package:charcode/charcode.dart';
|
||||
import 'package:source_span/source_span.dart';
|
||||
import 'package:string_scanner/string_scanner.dart';
|
||||
import 'exception.dart';
|
||||
import 'range_header.dart';
|
||||
import 'range_header_impl.dart';
|
||||
import 'range_header_item.dart';
|
||||
|
||||
final RegExp _rgxInt = new RegExp(r'[0-9]+');
|
||||
final RegExp _rgxWs = new RegExp(r'[ \n\r\t]');
|
||||
|
||||
enum TokenType { RANGE_UNIT, COMMA, INT, DASH, EQUALS }
|
||||
|
||||
class Token {
|
||||
final TokenType type;
|
||||
final SourceSpan span;
|
||||
|
||||
Token(this.type, this.span);
|
||||
}
|
||||
|
||||
List<Token> scan(String text, List<String> allowedRangeUnits) {
|
||||
List<Token> tokens = [];
|
||||
var scanner = new SpanScanner(text);
|
||||
|
||||
while (!scanner.isDone) {
|
||||
// Skip whitespace
|
||||
scanner.scan(_rgxWs);
|
||||
|
||||
if (scanner.scanChar($comma))
|
||||
tokens.add(new Token(TokenType.COMMA, scanner.lastSpan));
|
||||
else if (scanner.scanChar($dash))
|
||||
tokens.add(new Token(TokenType.DASH, scanner.lastSpan));
|
||||
else if (scanner.scan(_rgxInt))
|
||||
tokens.add(new Token(TokenType.INT, scanner.lastSpan));
|
||||
else if (scanner.scanChar($equal))
|
||||
tokens.add(new Token(TokenType.EQUALS, scanner.lastSpan));
|
||||
else {
|
||||
bool matched = false;
|
||||
|
||||
for (var unit in allowedRangeUnits) {
|
||||
if (scanner.scan(unit)) {
|
||||
tokens.add(new Token(TokenType.RANGE_UNIT, scanner.lastSpan));
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
var ch = scanner.readChar();
|
||||
throw new RangeHeaderParseException(
|
||||
'Unexpected character: "${new String.fromCharCode(ch)}"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
Token _current;
|
||||
int _index = -1;
|
||||
final List<Token> tokens;
|
||||
|
||||
Parser(this.tokens);
|
||||
|
||||
Token get current => _current;
|
||||
|
||||
bool get done => _index >= tokens.length - 1;
|
||||
|
||||
RangeHeaderParseException _expected(String type) {
|
||||
int offset = current?.span?.start?.offset;
|
||||
|
||||
if (offset == null) return new RangeHeaderParseException('Expected $type.');
|
||||
|
||||
Token peek;
|
||||
|
||||
if (_index < tokens.length - 1) peek = tokens[_index + 1];
|
||||
|
||||
if (peek != null && peek.span != null) {
|
||||
return new RangeHeaderParseException(
|
||||
'Expected $type at offset $offset, found "${peek.span.text}" instead. \nSource:\n${peek.span?.highlight() ?? peek.type}');
|
||||
} else
|
||||
return new RangeHeaderParseException(
|
||||
'Expected $type at offset $offset, but the header string ended without one.\nSource:\n${current.span?.highlight() ?? current.type}');
|
||||
}
|
||||
|
||||
bool next(TokenType type) {
|
||||
if (done) return false;
|
||||
var tok = tokens[_index + 1];
|
||||
if (tok.type == type) {
|
||||
_index++;
|
||||
_current = tok;
|
||||
return true;
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
|
||||
RangeHeader parseRangeHeader() {
|
||||
if (next(TokenType.RANGE_UNIT)) {
|
||||
var unit = current.span.text;
|
||||
next(TokenType.EQUALS); // Consume =, if any.
|
||||
|
||||
List<RangeHeaderItem> items = [];
|
||||
RangeHeaderItem item = parseHeaderItem();
|
||||
|
||||
while (item != null) {
|
||||
items.add(item);
|
||||
// Parse comma
|
||||
if (next(TokenType.COMMA)) {
|
||||
item = parseHeaderItem();
|
||||
} else
|
||||
item = null;
|
||||
}
|
||||
|
||||
if (items.isEmpty)
|
||||
throw _expected('range');
|
||||
else
|
||||
return new RangeHeaderImpl(unit, items);
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
|
||||
RangeHeaderItem parseHeaderItem() {
|
||||
if (next(TokenType.INT)) {
|
||||
// i.e 500-544, or 600-
|
||||
var start = int.parse(current.span.text);
|
||||
if (next(TokenType.DASH)) {
|
||||
if (next(TokenType.INT)) {
|
||||
return new RangeHeaderItem(start, int.parse(current.span.text));
|
||||
} else
|
||||
return new RangeHeaderItem(start);
|
||||
} else
|
||||
throw _expected('"-"');
|
||||
} else if (next(TokenType.DASH)) {
|
||||
// i.e. -599
|
||||
if (next(TokenType.INT)) {
|
||||
return new RangeHeaderItem(-1, int.parse(current.span.text));
|
||||
} else
|
||||
throw _expected('integer');
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
}
|
63
packages/range_header/lib/src/range_header.dart
Normal file
63
packages/range_header/lib/src/range_header.dart
Normal file
|
@ -0,0 +1,63 @@
|
|||
import 'dart:collection';
|
||||
import 'parser.dart';
|
||||
import 'range_header_item.dart';
|
||||
import 'range_header_impl.dart';
|
||||
|
||||
/// Represents the contents of a parsed `Range` header.
|
||||
abstract class RangeHeader {
|
||||
/// Returns an immutable list of the ranges that were parsed.
|
||||
UnmodifiableListView<RangeHeaderItem> get items;
|
||||
|
||||
const factory RangeHeader(Iterable<RangeHeaderItem> items,
|
||||
{String rangeUnit}) = _ConstantRangeHeader;
|
||||
|
||||
/// Eliminates any overlapping [items], sorts them, and folds them all into the most efficient representation possible.
|
||||
static UnmodifiableListView<RangeHeaderItem> foldItems(
|
||||
Iterable<RangeHeaderItem> items) {
|
||||
var out = new Set<RangeHeaderItem>();
|
||||
|
||||
for (var item in items) {
|
||||
// Remove any overlapping items, consolidate them.
|
||||
while (out.any((x) => x.overlaps(item))) {
|
||||
var f = out.firstWhere((x) => x.overlaps(item));
|
||||
out.remove(f);
|
||||
item = item.consolidate(f);
|
||||
}
|
||||
|
||||
out.add(item);
|
||||
}
|
||||
|
||||
return new UnmodifiableListView(out.toList()..sort());
|
||||
}
|
||||
|
||||
/// Attempts to parse a [RangeHeader] from its [text] representation.
|
||||
///
|
||||
/// You can optionally pass a custom list of [allowedRangeUnits].
|
||||
/// The default is `['bytes']`.
|
||||
///
|
||||
/// If [fold] is `true`, the items will be folded into the most compact
|
||||
/// possible representation.
|
||||
factory RangeHeader.parse(String text,
|
||||
{Iterable<String> allowedRangeUnits, bool fold: true}) {
|
||||
var tokens = scan(text, allowedRangeUnits?.toList() ?? ['bytes']);
|
||||
var parser = new Parser(tokens);
|
||||
var header = parser.parseRangeHeader();
|
||||
if (header == null) return null;
|
||||
var items = foldItems(header.items);
|
||||
return RangeHeaderImpl(header.rangeUnit, items);
|
||||
}
|
||||
|
||||
/// Returns this header's range unit. Most commonly, this is `bytes`.
|
||||
String get rangeUnit;
|
||||
}
|
||||
|
||||
class _ConstantRangeHeader implements RangeHeader {
|
||||
final Iterable<RangeHeaderItem> items_;
|
||||
final String rangeUnit;
|
||||
|
||||
const _ConstantRangeHeader(this.items_, {this.rangeUnit: 'bytes'});
|
||||
|
||||
@override
|
||||
UnmodifiableListView<RangeHeaderItem> get items =>
|
||||
new UnmodifiableListView(items_);
|
||||
}
|
20
packages/range_header/lib/src/range_header_impl.dart
Normal file
20
packages/range_header/lib/src/range_header_impl.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import 'dart:collection';
|
||||
import 'range_header.dart';
|
||||
import 'range_header_item.dart';
|
||||
|
||||
/// Represents the contents of a parsed `Range` header.
|
||||
class RangeHeaderImpl implements RangeHeader {
|
||||
UnmodifiableListView<RangeHeaderItem> _cached;
|
||||
final List<RangeHeaderItem> _items = [];
|
||||
|
||||
RangeHeaderImpl(this.rangeUnit, [List<RangeHeaderItem> items = const []]) {
|
||||
this._items.addAll(items ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
UnmodifiableListView<RangeHeaderItem> get items =>
|
||||
_cached ??= new UnmodifiableListView<RangeHeaderItem>(_items);
|
||||
|
||||
@override
|
||||
final String rangeUnit;
|
||||
}
|
90
packages/range_header/lib/src/range_header_item.dart
Normal file
90
packages/range_header/lib/src/range_header_item.dart
Normal file
|
@ -0,0 +1,90 @@
|
|||
import 'dart:math';
|
||||
import 'package:quiver_hashcode/hashcode.dart';
|
||||
|
||||
/// Represents an individual range, with an optional start index and optional end index.
|
||||
class RangeHeaderItem implements Comparable<RangeHeaderItem> {
|
||||
/// The index at which this chunk begins. May be `-1`.
|
||||
final int start;
|
||||
|
||||
/// The index at which this chunk ends. May be `-1`.
|
||||
final int end;
|
||||
|
||||
const RangeHeaderItem([this.start = -1, this.end = -1]);
|
||||
|
||||
/// Joins two items together into the largest possible range.
|
||||
RangeHeaderItem consolidate(RangeHeaderItem other) {
|
||||
if (!(other.overlaps(this)))
|
||||
throw new ArgumentError('The two ranges do not overlap.');
|
||||
return new RangeHeaderItem(min(start, other.start), max(end, other.end));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hash2(start, end);
|
||||
|
||||
@override
|
||||
bool operator ==(other) =>
|
||||
other is RangeHeaderItem && other.start == start && other.end == end;
|
||||
|
||||
bool overlaps(RangeHeaderItem other) {
|
||||
if (other.start <= start) {
|
||||
return other.end < start;
|
||||
} else if (other.start > start) {
|
||||
return other.start <= end;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int compareTo(RangeHeaderItem other) {
|
||||
if (other.start > start) {
|
||||
return -1;
|
||||
} else if (other.start == start) {
|
||||
if (other.end == end) {
|
||||
return 0;
|
||||
} else if (other.end < end) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} else if (other.start < start) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (start > -1 && end > -1)
|
||||
return '$start-$end';
|
||||
else if (start > -1)
|
||||
return '$start-';
|
||||
else
|
||||
return '-$end';
|
||||
}
|
||||
|
||||
/// Creates a representation of this instance suitable for a `Content-Range` header.
|
||||
///
|
||||
/// This can only be used if the user request only one range. If not, send a
|
||||
/// `multipart/byteranges` response.
|
||||
///
|
||||
/// Please adhere to the standard!!!
|
||||
/// http://httpwg.org/specs/rfc7233.html
|
||||
|
||||
String toContentRange([int totalSize]) {
|
||||
// var maxIndex = totalSize != null ? (totalSize - 1).toString() : '*';
|
||||
var s = start > -1 ? start : 0;
|
||||
|
||||
if (end == -1) {
|
||||
if (totalSize == null) {
|
||||
throw new UnsupportedError(
|
||||
'If the end of this range is unknown, `totalSize` must not be null.');
|
||||
} else {
|
||||
// if (end == totalSize - 1) {
|
||||
return '$s-${totalSize - 1}/$totalSize';
|
||||
}
|
||||
}
|
||||
|
||||
return '$s-$end/$totalSize';
|
||||
}
|
||||
}
|
20
packages/range_header/pubspec.yaml
Normal file
20
packages/range_header/pubspec.yaml
Normal file
|
@ -0,0 +1,20 @@
|
|||
name: range_header
|
||||
version: 2.0.2+2
|
||||
description: Range header parser for Dart. Beyond parsing, a stream transformer is included.
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/thosakwe/range_header
|
||||
environment:
|
||||
sdk: ">=2.0.0-dev <3.0.0"
|
||||
dependencies:
|
||||
async: ^2.0.0
|
||||
charcode: ^1.0.0
|
||||
quiver_hashcode: ^2.0.0
|
||||
source_span: ^1.0.0
|
||||
string_scanner: ^1.0.0
|
||||
dev_dependencies:
|
||||
angel_framework:
|
||||
angel_static: ^2.0.0
|
||||
file:
|
||||
http_parser: ^3.0.0
|
||||
logging: ^0.11.0
|
||||
test: ^1.0.0
|
96
packages/range_header/test/all_test.dart
Normal file
96
packages/range_header/test/all_test.dart
Normal file
|
@ -0,0 +1,96 @@
|
|||
import 'package:range_header/range_header.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final Matcher throwsRangeParseException =
|
||||
throwsA(const TypeMatcher<RangeHeaderParseException>());
|
||||
|
||||
main() {
|
||||
group('one item', () {
|
||||
test('start and end', () {
|
||||
var r = new RangeHeader.parse('bytes 1-200');
|
||||
expect(r.items, hasLength(1));
|
||||
expect(r.items.first.start, 1);
|
||||
expect(r.items.first.end, 200);
|
||||
});
|
||||
|
||||
test('start only', () {
|
||||
var r = new RangeHeader.parse('bytes 1-');
|
||||
expect(r.items, hasLength(1));
|
||||
expect(r.items.first.start, 1);
|
||||
expect(r.items.first.end, -1);
|
||||
});
|
||||
|
||||
test('end only', () {
|
||||
var r = new RangeHeader.parse('bytes -200');
|
||||
print(r.items);
|
||||
expect(r.items, hasLength(1));
|
||||
expect(r.items.first.start, -1);
|
||||
expect(r.items.first.end, 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('multiple items', () {
|
||||
test('three items', () {
|
||||
var r = new RangeHeader.parse('bytes 1-20, 21-40, 41-60');
|
||||
print(r.items);
|
||||
expect(r.items, hasLength(3));
|
||||
expect(r.items[0].start, 1);
|
||||
expect(r.items[0].end, 20);
|
||||
expect(r.items[1].start, 21);
|
||||
expect(r.items[1].end, 40);
|
||||
expect(r.items[2].start, 41);
|
||||
expect(r.items[2].end, 60);
|
||||
});
|
||||
|
||||
test('one item without end', () {
|
||||
var r = new RangeHeader.parse('bytes 1-20, 21-');
|
||||
print(r.items);
|
||||
expect(r.items, hasLength(2));
|
||||
expect(r.items[0].start, 1);
|
||||
expect(r.items[0].end, 20);
|
||||
expect(r.items[1].start, 21);
|
||||
expect(r.items[1].end, -1);
|
||||
});
|
||||
});
|
||||
|
||||
group('failures', () {
|
||||
test('no start with no end', () {
|
||||
expect(new RangeHeader.parse('-'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('exceptions', () {
|
||||
test('invalid character', () {
|
||||
expect(() => new RangeHeader.parse('!!!'), throwsRangeParseException);
|
||||
});
|
||||
|
||||
test('no ranges', () {
|
||||
expect(() => new RangeHeader.parse('bytes'), throwsRangeParseException);
|
||||
});
|
||||
|
||||
test('no dash after int', () {
|
||||
expect(() => new RangeHeader.parse('bytes 3'), throwsRangeParseException);
|
||||
expect(
|
||||
() => new RangeHeader.parse('bytes 3,'), throwsRangeParseException);
|
||||
expect(
|
||||
() => new RangeHeader.parse('bytes 3 24'), throwsRangeParseException);
|
||||
});
|
||||
|
||||
test('no int after dash', () {
|
||||
expect(
|
||||
() => new RangeHeader.parse('bytes -,'), throwsRangeParseException);
|
||||
});
|
||||
});
|
||||
|
||||
group('complete coverage', () {
|
||||
test('exception toString()', () {
|
||||
var m = new RangeHeaderParseException('hey');
|
||||
expect(m.toString(), contains('hey'));
|
||||
});
|
||||
});
|
||||
|
||||
test('content-range', () {
|
||||
expect(
|
||||
new RangeHeader.parse('bytes 1-2').items[0].toContentRange(3), '1-2/3');
|
||||
});
|
||||
}
|
|
@ -4,7 +4,7 @@ import 'package:angel_static/angel_static.dart';
|
|||
import 'package:file/local.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
main(List<String> args) async {
|
||||
void main(List<String> args) async {
|
||||
var app = Angel();
|
||||
var http = AngelHttp(app);
|
||||
var fs = const LocalFileSystem();
|
||||
|
|
|
@ -49,7 +49,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
bool allowDirectoryListing,
|
||||
bool useBuffer = false,
|
||||
String publicPath,
|
||||
callback(File file, RequestContext req, ResponseContext res)})
|
||||
Function(File file, RequestContext req, ResponseContext res) callback})
|
||||
: super(app, fileSystem,
|
||||
source: source,
|
||||
indexFileNames: indexFileNames ?? ['index.html'],
|
||||
|
@ -67,7 +67,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
return super.serveFile(file, stat, req, res);
|
||||
}
|
||||
|
||||
bool shouldNotCache = noCache == true;
|
||||
var shouldNotCache = noCache == true;
|
||||
|
||||
if (!shouldNotCache) {
|
||||
shouldNotCache = req.headers.value('cache-control') == 'no-cache' ||
|
||||
|
@ -79,7 +79,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
return super.serveFile(file, stat, req, res);
|
||||
} else {
|
||||
var ifModified = req.headers.ifModifiedSince;
|
||||
bool ifRange = false;
|
||||
var ifRange = false;
|
||||
|
||||
try {
|
||||
ifModified = HttpDate.parse(req.headers.value('if-range'));
|
||||
|
@ -129,7 +129,7 @@ class CachingVirtualDirectory extends VirtualDirectory {
|
|||
}
|
||||
|
||||
if (etagsToMatchAgainst?.isNotEmpty == true) {
|
||||
bool hasBeenModified = false;
|
||||
var hasBeenModified = false;
|
||||
|
||||
for (var etag in etagsToMatchAgainst) {
|
||||
if (etag == '*') {
|
||||
|
|
|
@ -225,7 +225,7 @@ class VirtualDirectory {
|
|||
res.write('<li><a href="$href">$type $stub</a></li>');
|
||||
}
|
||||
|
||||
res..write('</body></html>');
|
||||
res.write('</body></html>');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ dependencies:
|
|||
angel_framework:
|
||||
git:
|
||||
url: https://github.com/dukefirehawk/angel.git
|
||||
ref: sdk-2.12.x
|
||||
ref: sdk-2.12.x_nnbd
|
||||
path: packages/framework
|
||||
convert: ^3.0.0
|
||||
crypto: ^3.0.0
|
||||
|
@ -22,7 +22,7 @@ dev_dependencies:
|
|||
angel_test:
|
||||
git:
|
||||
url: https://github.com/dukefirehawk/angel.git
|
||||
ref: sdk-2.12.x
|
||||
ref: sdk-2.12.x_nnbd
|
||||
path: packages/test
|
||||
http:
|
||||
logging: ^1.0.0
|
||||
|
|
Loading…
Reference in a new issue