Add 'packages/seo/' from commit '43e953044bb7f8eb8fc4c0d11f49e9cd53583e48'

git-subtree-dir: packages/seo
git-subtree-mainline: e271a0eafd
git-subtree-split: 43e953044b
This commit is contained in:
Tobe O 2020-02-15 18:29:02 -05:00
commit 9458a72c57
16 changed files with 495 additions and 0 deletions

13
packages/seo/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,4 @@
language: dart
dart:
- dev
- stable

View file

@ -0,0 +1,5 @@
# 2.0.0
* Angel 2 updates.
# 1.0.0
* Initial release.

21
packages/seo/LICENSE Normal file
View 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
View 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}');
}
```

View file

@ -0,0 +1,3 @@
analyzer:
strong-mode:
implicit-casts: false

View 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}');
}

View 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>

View file

@ -0,0 +1,3 @@
p {
font-style: italic;
}

View file

@ -0,0 +1,3 @@
window.addEventListener('load', function() {
console.log('THIS message was not from an inlined file.');
});

View file

@ -0,0 +1,3 @@
h1 {
color: pink;
}

View file

@ -0,0 +1,3 @@
window.addEventListener('load', function() {
console.log('Hello, inline world!');
});

View file

@ -0,0 +1 @@
export 'src/inline_assets.dart';

View 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
View 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

View 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!');
});'''
};