diff --git a/packages/seo/.gitignore b/packages/seo/.gitignore new file mode 100644 index 00000000..7bf00e82 --- /dev/null +++ b/packages/seo/.gitignore @@ -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/ diff --git a/packages/seo/.travis.yml b/packages/seo/.travis.yml new file mode 100644 index 00000000..a9e2c109 --- /dev/null +++ b/packages/seo/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/packages/seo/CHANGELOG.md b/packages/seo/CHANGELOG.md new file mode 100644 index 00000000..5371b09c --- /dev/null +++ b/packages/seo/CHANGELOG.md @@ -0,0 +1,5 @@ +# 2.0.0 +* Angel 2 updates. + +# 1.0.0 +* Initial release. \ No newline at end of file diff --git a/packages/seo/LICENSE b/packages/seo/LICENSE new file mode 100644 index 00000000..f8e6088a --- /dev/null +++ b/packages/seo/LICENSE @@ -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. diff --git a/packages/seo/README.md b/packages/seo/README.md new file mode 100644 index 00000000..4b64712e --- /dev/null +++ b/packages/seo/README.md @@ -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 + +``` + +## `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}'); +} +``` \ No newline at end of file diff --git a/packages/seo/analysis_options.yaml b/packages/seo/analysis_options.yaml new file mode 100644 index 00000000..eae1e42a --- /dev/null +++ b/packages/seo/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/packages/seo/example/main.dart b/packages/seo/example/main.dart new file mode 100644 index 00000000..cbf11339 --- /dev/null +++ b/packages/seo/example/main.dart @@ -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}'); +} diff --git a/packages/seo/example/web/index.html b/packages/seo/example/web/index.html new file mode 100644 index 00000000..50161197 --- /dev/null +++ b/packages/seo/example/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + Angel SEO + + +

Angel SEO

+

Embrace the power of inlined styles, etc.

+ + \ No newline at end of file diff --git a/packages/seo/example/web/not-inlined.css b/packages/seo/example/web/not-inlined.css new file mode 100644 index 00000000..ab2a12a3 --- /dev/null +++ b/packages/seo/example/web/not-inlined.css @@ -0,0 +1,3 @@ +p { + font-style: italic; +} \ No newline at end of file diff --git a/packages/seo/example/web/not-inlined.js b/packages/seo/example/web/not-inlined.js new file mode 100644 index 00000000..6a8ad492 --- /dev/null +++ b/packages/seo/example/web/not-inlined.js @@ -0,0 +1,3 @@ +window.addEventListener('load', function() { + console.log('THIS message was not from an inlined file.'); +}); \ No newline at end of file diff --git a/packages/seo/example/web/site.css b/packages/seo/example/web/site.css new file mode 100644 index 00000000..acbbda4b --- /dev/null +++ b/packages/seo/example/web/site.css @@ -0,0 +1,3 @@ +h1 { + color: pink; +} \ No newline at end of file diff --git a/packages/seo/example/web/site.js b/packages/seo/example/web/site.js new file mode 100644 index 00000000..283b1eff --- /dev/null +++ b/packages/seo/example/web/site.js @@ -0,0 +1,3 @@ +window.addEventListener('load', function() { + console.log('Hello, inline world!'); +}); \ No newline at end of file diff --git a/packages/seo/lib/angel_seo.dart b/packages/seo/lib/angel_seo.dart new file mode 100644 index 00000000..8a291cdc --- /dev/null +++ b/packages/seo/lib/angel_seo.dart @@ -0,0 +1 @@ +export 'src/inline_assets.dart'; diff --git a/packages/seo/lib/src/inline_assets.dart b/packages/seo/lib/src/inline_assets.dart new file mode 100644 index 00000000..95d5fab4 --- /dev/null +++ b/packages/seo/lib/src/inline_assets.dart @@ -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.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') ?? + []; + + 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 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); + } + } +} diff --git a/packages/seo/pubspec.yaml b/packages/seo/pubspec.yaml new file mode 100644 index 00000000..08b1a91b --- /dev/null +++ b/packages/seo/pubspec.yaml @@ -0,0 +1,18 @@ +name: angel_seo +description: Helper infrastructure for building SEO-friendly Web backends in Angel. +author: Tobe O +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 \ No newline at end of file diff --git a/packages/seo/test/inline_assets_test.dart b/packages/seo/test/inline_assets_test.dart new file mode 100644 index 00000000..d2d02199 --- /dev/null +++ b/packages/seo/test/inline_assets_test.dart @@ -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 with