Add 'packages/seo/' from commit '43e953044bb7f8eb8fc4c0d11f49e9cd53583e48'
git-subtree-dir: packages/seo git-subtree-mainline:e271a0eafd
git-subtree-split:43e953044b
This commit is contained in:
commit
9458a72c57
16 changed files with 495 additions and 0 deletions
13
packages/seo/.gitignore
vendored
Normal file
13
packages/seo/.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
# See https://www.dartlang.org/guides/libraries/private-files
|
||||
|
||||
# Files and directories created by pub
|
||||
.dart_tool/
|
||||
.packages
|
||||
.pub/
|
||||
build/
|
||||
# If you're building an application, you may want to check-in your pubspec.lock
|
||||
pubspec.lock
|
||||
|
||||
# Directory created by dartdoc
|
||||
# If you don't generate documentation locally you can remove this line.
|
||||
doc/api/
|
4
packages/seo/.travis.yml
Normal file
4
packages/seo/.travis.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
language: dart
|
||||
dart:
|
||||
- dev
|
||||
- stable
|
5
packages/seo/CHANGELOG.md
Normal file
5
packages/seo/CHANGELOG.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# 2.0.0
|
||||
* Angel 2 updates.
|
||||
|
||||
# 1.0.0
|
||||
* Initial release.
|
21
packages/seo/LICENSE
Normal file
21
packages/seo/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2018 The Angel Framework
|
||||
|
||||
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.
|
82
packages/seo/README.md
Normal file
82
packages/seo/README.md
Normal file
|
@ -0,0 +1,82 @@
|
|||
# seo
|
||||
[![Pub](https://img.shields.io/pub/v/angel_seo.svg)](https://pub.dartlang.org/packages/angel_seo)
|
||||
[![build status](https://travis-ci.org/angel-dart/seo.svg?branch=master)](https://travis-ci.org/angel-dart/seo)
|
||||
|
||||
Helpers for building SEO-friendly Web pages in Angel. The goal of
|
||||
`package:angel_seo` is to speed up perceived client page loads, prevent
|
||||
the infamous
|
||||
[flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content),
|
||||
and other SEO optimizations that can easily become tedious to perform by hand.
|
||||
|
||||
## Disabling inlining per-element
|
||||
Add a `data-no-inline` attribute to a `link` or `script` to prevent inlining it:
|
||||
|
||||
```html
|
||||
<script src="main.dart.js" data-no-inline></script>
|
||||
```
|
||||
|
||||
## `inlineAssets`
|
||||
A
|
||||
[response finalizer](https://angel-dart.gitbook.io/angel/the-basics/request-lifecycle)
|
||||
that can be used in any application to patch HTML responses, including those sent with
|
||||
a templating engine like Jael.
|
||||
|
||||
In any `text/html` response sent down, `link` and `script` elements that point to internal resources
|
||||
will have the contents of said file read, and inlined into the HTML page itself.
|
||||
|
||||
In this case, "internal resources" refers to a URI *without* a scheme, i.e. `/site.css` or
|
||||
`foo/bar/baz.js`.
|
||||
|
||||
```dart
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_seo/angel_seo.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/local.dart';
|
||||
|
||||
main() async {
|
||||
var app = new Angel()..lazyParseBodies = true;
|
||||
var fs = const LocalFileSystem();
|
||||
var http = new AngelHttp(app);
|
||||
|
||||
app.responseFinalizers.add(inlineAssets(fs.directory('web')));
|
||||
|
||||
app.use(() => throw new AngelHttpException.notFound());
|
||||
|
||||
var server = await http.startServer('127.0.0.1', 3000);
|
||||
print('Listening at http://${server.address.address}:${server.port}');
|
||||
}
|
||||
```
|
||||
|
||||
## `inlineAssetsFromVirtualDirectory`
|
||||
This function is a simple one; it wraps a `VirtualDirectory` to patch the way it sends
|
||||
`.html` files.
|
||||
|
||||
Produces the same functionality as `inlineAssets`.
|
||||
|
||||
```dart
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_seo/angel_seo.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/local.dart';
|
||||
|
||||
main() async {
|
||||
var app = new Angel()..lazyParseBodies = true;
|
||||
var fs = const LocalFileSystem();
|
||||
var http = new AngelHttp(app);
|
||||
|
||||
var vDir = inlineAssets(
|
||||
new VirtualDirectory(
|
||||
app,
|
||||
fs,
|
||||
source: fs.directory('web'),
|
||||
),
|
||||
);
|
||||
|
||||
app.use(vDir.handleRequest);
|
||||
|
||||
app.use(() => throw new AngelHttpException.notFound());
|
||||
|
||||
var server = await http.startServer('127.0.0.1', 3000);
|
||||
print('Listening at http://${server.address.address}:${server.port}');
|
||||
}
|
||||
```
|
3
packages/seo/analysis_options.yaml
Normal file
3
packages/seo/analysis_options.yaml
Normal file
|
@ -0,0 +1,3 @@
|
|||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
43
packages/seo/example/main.dart
Normal file
43
packages/seo/example/main.dart
Normal file
|
@ -0,0 +1,43 @@
|
|||
import 'dart:convert';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_seo/angel_seo.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/local.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
main() async {
|
||||
var app = new Angel();
|
||||
var fs = const LocalFileSystem();
|
||||
var http = new AngelHttp(app);
|
||||
|
||||
// You can wrap a [VirtualDirectory]
|
||||
var vDir = inlineAssetsFromVirtualDirectory(
|
||||
new VirtualDirectory(
|
||||
app,
|
||||
fs,
|
||||
source: fs.directory('web'),
|
||||
),
|
||||
);
|
||||
|
||||
app.fallback(vDir.handleRequest);
|
||||
|
||||
// OR, just add a finalizer. Note that [VirtualDirectory] *streams* its response,
|
||||
// so a response finalizer does not touch its contents.
|
||||
//
|
||||
// You likely won't need to use both; it just depends on your use case.
|
||||
app.responseFinalizers.add(inlineAssets(fs.directory('web')));
|
||||
|
||||
app.get('/using_response_buffer', (req, res) async {
|
||||
var indexHtml = fs.directory('web').childFile('index.html');
|
||||
var contents = await indexHtml.readAsString();
|
||||
res
|
||||
..contentType = new MediaType('text', 'html', {'charset': 'utf-8'})
|
||||
..buffer.add(utf8.encode(contents));
|
||||
});
|
||||
|
||||
app.fallback((req, res) => throw new AngelHttpException.notFound());
|
||||
|
||||
var server = await http.startServer('127.0.0.1', 3000);
|
||||
print('Listening at http://${server.address.address}:${server.port}');
|
||||
}
|
18
packages/seo/example/web/index.html
Normal file
18
packages/seo/example/web/index.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<link rel="stylesheet" href="site.css">
|
||||
<link rel="stylesheet" href="not-inlined.css" data-no-inline>
|
||||
<script src="site.js"></script>
|
||||
<script src="not-inlined.js" data-no-inline></script>
|
||||
<title>Angel SEO</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Angel SEO</h1>
|
||||
<p>Embrace the power of inlined styles, etc.</p>
|
||||
</body>
|
||||
</html>
|
3
packages/seo/example/web/not-inlined.css
Normal file
3
packages/seo/example/web/not-inlined.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
p {
|
||||
font-style: italic;
|
||||
}
|
3
packages/seo/example/web/not-inlined.js
Normal file
3
packages/seo/example/web/not-inlined.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
window.addEventListener('load', function() {
|
||||
console.log('THIS message was not from an inlined file.');
|
||||
});
|
3
packages/seo/example/web/site.css
Normal file
3
packages/seo/example/web/site.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
h1 {
|
||||
color: pink;
|
||||
}
|
3
packages/seo/example/web/site.js
Normal file
3
packages/seo/example/web/site.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
window.addEventListener('load', function() {
|
||||
console.log('Hello, inline world!');
|
||||
});
|
1
packages/seo/lib/angel_seo.dart
Normal file
1
packages/seo/lib/angel_seo.dart
Normal file
|
@ -0,0 +1 @@
|
|||
export 'src/inline_assets.dart';
|
120
packages/seo/lib/src/inline_assets.dart
Normal file
120
packages/seo/lib/src/inline_assets.dart
Normal file
|
@ -0,0 +1,120 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:html/dom.dart' as html;
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Inlines assets into buffered responses, resolving paths from an [assetDirectory].
|
||||
///
|
||||
/// In any `.html` file sent down, `link` and `script` elements that point to internal resources
|
||||
/// will have the contents of said file read, and inlined into the HTML page itself.
|
||||
///
|
||||
/// In this case, "internal resources" refers to a URI *without* a scheme, i.e. `/site.css` or
|
||||
/// `foo/bar/baz.js`.
|
||||
RequestHandler inlineAssets(Directory assetDirectory) {
|
||||
return (req, res) {
|
||||
if (!res.isOpen ||
|
||||
!res.isBuffered ||
|
||||
res.contentType.mimeType != 'text/html') {
|
||||
return new Future<bool>.value(true);
|
||||
} else {
|
||||
var doc = html.parse(utf8.decode(res.buffer.takeBytes()));
|
||||
return inlineAssetsIntoDocument(doc, assetDirectory).then((_) {
|
||||
res.buffer.add(utf8.encode(doc.outerHtml));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Wraps a `VirtualDirectory` to patch the way it sends
|
||||
/// `.html` files.
|
||||
///
|
||||
/// In any `.html` file sent down, `link` and `script` elements that point to internal resources
|
||||
/// will have the contents of said file read, and inlined into the HTML page itself.
|
||||
///
|
||||
/// In this case, "internal resources" refers to a URI *without* a scheme, i.e. `/site.css` or
|
||||
/// `foo/bar/baz.js`.
|
||||
VirtualDirectory inlineAssetsFromVirtualDirectory(VirtualDirectory vDir) =>
|
||||
new _InlineAssets(vDir);
|
||||
|
||||
/// Replaces `link` and `script` tags within a [doc] with the static contents they would otherwise trigger an HTTP request to.
|
||||
///
|
||||
/// Powers both [inlineAssets] and [inlineAssetsFromVirtualDirectory].
|
||||
Future inlineAssetsIntoDocument(
|
||||
html.Document doc, Directory assetDirectory) async {
|
||||
var linksWithRel = doc.head
|
||||
?.getElementsByTagName('link')
|
||||
?.where((link) => link.attributes['rel'] == 'stylesheet') ??
|
||||
<html.Element>[];
|
||||
|
||||
for (var link in linksWithRel) {
|
||||
if (link.attributes.containsKey('data-no-inline')) {
|
||||
link.attributes.remove('data-no-inline');
|
||||
} else {
|
||||
var uri = Uri.parse(link.attributes['href']);
|
||||
|
||||
if (uri.scheme.isEmpty) {
|
||||
var styleFile = assetDirectory.childFile(uri.path);
|
||||
|
||||
if (await styleFile.exists()) {
|
||||
var style = new html.Element.tag('style')
|
||||
..innerHtml = await styleFile.readAsString();
|
||||
link.replaceWith(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var scripts = doc
|
||||
.getElementsByTagName('script')
|
||||
.where((script) => script.attributes.containsKey('src'));
|
||||
|
||||
for (var script in scripts) {
|
||||
if (script.attributes.containsKey('data-no-inline')) {
|
||||
script.attributes.remove('data-no-inline');
|
||||
} else {
|
||||
var uri = Uri.parse(script.attributes['src']);
|
||||
|
||||
if (uri.scheme.isEmpty) {
|
||||
var scriptFile = assetDirectory.childFile(uri.path);
|
||||
if (await scriptFile.exists()) {
|
||||
script.attributes.remove('src');
|
||||
script.innerHtml = await scriptFile.readAsString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InlineAssets extends VirtualDirectory {
|
||||
final VirtualDirectory inner;
|
||||
|
||||
_InlineAssets(this.inner)
|
||||
: super(inner.app, inner.fileSystem,
|
||||
source: inner.source,
|
||||
indexFileNames: inner.indexFileNames,
|
||||
publicPath: inner.publicPath,
|
||||
callback: inner.callback,
|
||||
allowDirectoryListing: inner.allowDirectoryListing);
|
||||
|
||||
@override
|
||||
Future<bool> serveFile(
|
||||
File file, FileStat stat, RequestContext req, ResponseContext res) async {
|
||||
if (p.extension(file.path) == '.html') {
|
||||
var contents = await file.readAsString();
|
||||
var doc = html.parse(contents, sourceUrl: file.uri.toString());
|
||||
await inlineAssetsIntoDocument(doc, inner.source);
|
||||
|
||||
res
|
||||
..headers['content-type'] = 'text/html; charset=utf8'
|
||||
..add(utf8.encode(doc.outerHtml));
|
||||
return false;
|
||||
} else {
|
||||
return await inner.serveFile(file, stat, req, res);
|
||||
}
|
||||
}
|
||||
}
|
18
packages/seo/pubspec.yaml
Normal file
18
packages/seo/pubspec.yaml
Normal file
|
@ -0,0 +1,18 @@
|
|||
name: angel_seo
|
||||
description: Helper infrastructure for building SEO-friendly Web backends in Angel.
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/seo
|
||||
version: 2.0.0
|
||||
environment:
|
||||
sdk: ">=2.0.0-dev <3.0.0"
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0-alpha
|
||||
angel_static: ^2.0.0-alpha
|
||||
file: ^5.0.0
|
||||
html: ^0.13.0
|
||||
http_parser: ^3.0.0
|
||||
path: ^1.0.0
|
||||
dev_dependencies:
|
||||
angel_test: ^2.0.0-alpha
|
||||
logging:
|
||||
test: ^1.0.0
|
155
packages/seo/test/inline_assets_test.dart
Normal file
155
packages/seo/test/inline_assets_test.dart
Normal file
|
@ -0,0 +1,155 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_seo/angel_seo.dart';
|
||||
import 'package:angel_static/angel_static.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/memory.dart';
|
||||
import 'package:html/dom.dart' as html;
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('inlineAssets', () {
|
||||
group('buffer', inlineAssetsTests((app, dir) {
|
||||
app.get('/', (req, res) async {
|
||||
var indexHtml = dir.childFile('index.html');
|
||||
var contents = await indexHtml.readAsBytes();
|
||||
res
|
||||
..useBuffer()
|
||||
..contentType = new MediaType.parse('text/html; charset=utf-8')
|
||||
..buffer.add(contents);
|
||||
});
|
||||
|
||||
app.responseFinalizers.add(inlineAssets(dir));
|
||||
}));
|
||||
|
||||
group('virtual_directory', inlineAssetsTests((app, dir) {
|
||||
var vDir = inlineAssetsFromVirtualDirectory(
|
||||
new VirtualDirectory(app, dir.fileSystem, source: dir));
|
||||
app.fallback(vDir.handleRequest);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Typedef for backwards-compatibility with Dart 1.
|
||||
typedef void InlineAssetTest(Angel app, Directory dir);
|
||||
|
||||
void Function() inlineAssetsTests(InlineAssetTest f) {
|
||||
return () {
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
var app = new Angel();
|
||||
var fs = new MemoryFileSystem();
|
||||
var dir = fs.currentDirectory;
|
||||
f(app, dir);
|
||||
client = await connectTo(app);
|
||||
|
||||
for (var path in contents.keys) {
|
||||
var file = fs.file(path);
|
||||
await file.writeAsString(contents[path].trim());
|
||||
}
|
||||
|
||||
app.logger = new Logger('angel_seo')
|
||||
..onRecord.listen((rec) {
|
||||
print(rec);
|
||||
if (rec.error != null) print(rec.error);
|
||||
if (rec.stackTrace != null) print(rec.stackTrace);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
group('sends html', () {
|
||||
html.Document doc;
|
||||
|
||||
setUp(() async {
|
||||
var res = await client.get('/', headers: {'accept': 'text/html'});
|
||||
print(res.body);
|
||||
doc = html.parse(res.body);
|
||||
});
|
||||
|
||||
group('stylesheets', () {
|
||||
test('replaces <link> with <style>', () {
|
||||
expect(doc.querySelectorAll('link'), hasLength(1));
|
||||
});
|
||||
|
||||
test('populates a <style>', () {
|
||||
var style = doc.querySelector('style');
|
||||
expect(style?.innerHtml?.trim(), contents['site.css']);
|
||||
});
|
||||
|
||||
test('heeds data-no-inline', () {
|
||||
var link = doc.querySelector('link');
|
||||
expect(link.attributes, containsPair('rel', 'stylesheet'));
|
||||
expect(link.attributes, containsPair('href', 'not-inlined.css'));
|
||||
expect(link.attributes.keys, isNot(contains('data-no-inline')));
|
||||
});
|
||||
|
||||
test('preserves other attributes', () {
|
||||
var link = doc.querySelector('link');
|
||||
expect(link.attributes, containsPair('data-foo', 'bar'));
|
||||
});
|
||||
});
|
||||
|
||||
group('scripts', () {
|
||||
test('does not replace <script> with anything', () {
|
||||
expect(doc.querySelectorAll('script'), hasLength(2));
|
||||
});
|
||||
|
||||
test('populates innerHtml', () {
|
||||
var script0 = doc.querySelectorAll('script')[0];
|
||||
expect(script0.innerHtml.trim(), contents['site.js']);
|
||||
});
|
||||
|
||||
test('heeds data-no-inline', () {
|
||||
var script1 = doc.querySelectorAll('script')[1];
|
||||
expect(script1.attributes, containsPair('src', 'not-inlined.js'));
|
||||
expect(script1.attributes.keys, isNot(contains('data-no-inline')));
|
||||
});
|
||||
|
||||
test('preserves other attributes', () {
|
||||
var script0 = doc.querySelectorAll('script')[0];
|
||||
var script1 = doc.querySelectorAll('script')[1];
|
||||
expect(script0.attributes, containsPair('data-foo', 'bar'));
|
||||
expect(script1.attributes, containsPair('type', 'text/javascript'));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var contents = <String, String>{
|
||||
'index.html': '''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<link rel="stylesheet" href="site.css">
|
||||
<link data-foo="bar" rel="stylesheet" href="not-inlined.css" data-no-inline>
|
||||
<script data-foo="bar" src="site.js"></script>
|
||||
<script type="text/javascript" src="not-inlined.js" data-no-inline></script>
|
||||
<title>Angel SEO</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Angel SEO</h1>
|
||||
<p>Embrace the power of inlined styles, etc.</p>
|
||||
</body>
|
||||
</html>''',
|
||||
'not-inlined.css': '''p {
|
||||
font-style: italic;
|
||||
}''',
|
||||
'not-inlined.js': '''window.addEventListener('load', function() {
|
||||
console.log('THIS message was not from an inlined file.');
|
||||
});''',
|
||||
'site.css': '''h1 {
|
||||
color: pink;
|
||||
}''',
|
||||
'site.js': '''window.addEventListener('load', function() {
|
||||
console.log('Hello, inline world!');
|
||||
});'''
|
||||
};
|
Loading…
Reference in a new issue