Giter Club home page Giter Club logo

craft-retour's Introduction

Scrutinizer Code Quality Build Status Code Intelligence Status Code Coverage

Retour plugin for Craft CMS 5.x

Retour allows you to intelligently redirect legacy URLs, so that you don't lose SEO value when rebuilding & restructuring a website

Screenshot

Related: Retour for Craft 2.x

Note: The license fee for this plugin is $59.00 via the Craft Plugin Store.

Upgrading from Retour 1.x for Craft CMS 2.x

Even though this version of Retour was entirely rewritten for Craft CMS 3, it was designed to use all of the same data used by the Craft CMS 2.x version of Retour.

So any existing redirects and statistics will continue to be in place.

Used By

ScreenshotScreenshot

Retour is the redirect tool that the SEO experts at Moz.com and the creators of Craft CMS, Pixel & Tonic, rely on to handle their website redirects!

Requirements

This plugin requires Craft CMS 5.0.0 or later.

Installation

To install the plugin, follow these instructions.

  1. Open your terminal and go to your Craft project:

     cd /path/to/project
    
  2. Then tell Composer to load the plugin:

     composer require nystudio107/craft-retour
    
  3. Install the plugin via ./craft install/plugin retour via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Retour.

You can also install Retour via the Plugin Store in the Craft Control Panel.

Documentation

Click here -> Retour Documentation

Retour Roadmap

Some things to do, and ideas for potential features:

  • Release it

Brought to you by nystudio107

craft-retour's People

Contributors

7ochem avatar alexthn avatar andersaloof avatar bencroker avatar billythekid avatar brandonkelly avatar brianjhanson avatar hastadhana avatar khalwat avatar markhuot avatar mmikkel avatar ostark avatar romanavr avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

craft-retour's Issues

Write documentation

Write up the documentation with updated screenshots for the 3.x version. Initially it will just be the README.md on github, but it will eventually be moved to nystudio107.com via VuePress.

[FR] Option to not have a "live" dashboard

The dashboard currently refreshes (three requests) every couple of seconds, which puts a toll on the server and makes the UX on the list of 404s terrible (because items move). Although I hate the whole idea of it, I can see how some people could find this interesting. But could we at least get a config setting to turn this off?

Plugin URL occasionally drops admin upon clicking "+" symbol

I can't recreate it every time; it seems to be on a per-error basis, but I'm getting an error where the "admin" part of the url occasionally drops out of the url bar and ironically creates a new 404 entry of its own. I can't reproduce it, but it will pop up at the most inopportune times!

oakland-error

Implement CSV file importing

Implement importing of a CSV file into Retour's static redirects. Ideally, allow them to specify via GUI exactly how to map the fields.

Worst-case, do so via config.php at first.

Imported routes don't appear in CP (but they exist!)

Retour v3.1.6

The client imported a CSV file of redirects... They told me "it didn't work," because the redirects were not showing up in the control panel.

But here's the catch... it did work. Turns out those redirects were stored in the database, they just weren't showing up in the CP for whatever reason! The redirects were still functioning properly, despite being invisible to us.

Database:

2019-02-15 at 5 09 32 pm

Control Panel:

2019-02-15 at 5 09 58 pm

Disabling the plugin disables the redirects. Enabling the plugin enables the redirects (though they still don't show up in the CP).

Missing error/warning when creating static redirects

When creating a new static redirect and setting the same value for Legacy URL Pattern and Destination URL fields, saving the form will display a success message but not actually save the redirect (it does not show in retour->redirects).

It should probably show some error/warning message.

I stumbled upon this when trying to create a redirect using the retourMatch plugin function, I assumed these fields could be left with their default values (both "/" ) as the matching and replacing is done inside the plugin. Turns out this is not how it works! ;-)

Serialization Failure

We have seen the following sporadic error generated by the plugin (captured by WebPerf):

image

[FR] True Multisite

It would be nice to handle site redirects on a multisite in a craft 3 multisite way.

This would mean that in the header there is a dropdown to switch which site is being worked on. Each site would have its own set of redirects. Currently Retour can only have multisite redirect by prefixing the redirect with the multisite folder prefix or full domain.

Widget icon file doesn't exist

Every time the dashboard is loaded there's a warning that the icon file doesn't exist in the logs. Specifically it's looking for this:

/vendor/nystudio107/craft-retour/src/assetbundles/retour/dist/img/icon-mask.svg

Undefined offset when saving entries (on non primary site)

We have a multi-site install. It looks like if we save an entry that is only available on a site that is not the primary site, we get an error:

yii\base\ErrorException: Undefined offset: 98356 in /storage/av05091/www/public_html/releases/20180914110132/vendor/nystudio107/craft-retour/src/Retour.php:444
Stack trace:
#0 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/web/ErrorHandler.php(76): yii\base\ErrorHandler->handleError(8, 'Undefined offse...', '/storage/av0509...', 444)
#1 /storage/av05091/www/public_html/releases/20180914110132/plugins/simplesentry/src/vendor/sentry/sentry/lib/Raven/Breadcrumbs/ErrorHandler.php(34): craft\web\ErrorHandler->handleError(8, 'Undefined offse...', '/storage/av0509...', 444, Array)
#2 /storage/av05091/www/public_html/releases/20180914110132/plugins/simplesentry/src/vendor/sentry/sentry/lib/Raven/ErrorHandler.php(115): Raven_Breadcrumbs_ErrorHandler->handleError(8, 'Undefined offse...', '/storage/av0509...', 444, Array)
#3 /storage/av05091/www/public_html/releases/20180914110132/vendor/nystudio107/craft-retour/src/Retour.php(444): Raven_ErrorHandler->handleError(8, 'Undefined offse...', '/storage/av0509...', 444, Array)
#4 /storage/av05091/www/public_html/releases/20180914110132/vendor/nystudio107/craft-retour/src/Retour.php(285): nystudio107\retour\Retour->handleElementUriChange(Object(craft\elements\Entry))
#5 [internal function]: nystudio107\retour\Retour->nystudio107\retour\{closure}(Object(craft\events\ElementEvent))
#6 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/Event.php(310): call_user_func(Object(Closure), Object(craft\events\ElementEvent))
#7 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/Component.php(636): yii\base\Event::trigger('craft\\services\\...', 'afterSaveElemen...', Object(craft\events\ElementEvent))
#8 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/services/Elements.php(542): yii\base\Component->trigger('afterSaveElemen...', Object(craft\events\ElementEvent))
#9 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/services/Elements.php(1395): craft\services\Elements->saveElement(Object(craft\elements\Entry), true, false)
#10 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/services/Elements.php(500): craft\services\Elements->_propagateElement(Object(craft\elements\Entry), false, Array)
#11 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/controllers/EntriesController.php(544): craft\services\Elements->saveElement(Object(craft\elements\Entry))
#12 [internal function]: craft\controllers\EntriesController->actionSaveEntry()
#13 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/InlineAction.php(57): call_user_func_array(Array, Array)
#14 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/Controller.php(157): yii\base\InlineAction->runWithParams(Array)
#15 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/web/Controller.php(103): yii\base\Controller->runAction('save-entry', Array)
#16 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/Module.php(528): craft\web\Controller->runAction('save-entry', Array)
#17 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/web/Application.php(282): yii\base\Module->runAction('entries/save-en...', Array)
#18 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/web/Application.php(541): craft\web\Application->runAction('entries/save-en...', Array)
#19 /storage/av05091/www/public_html/releases/20180914110132/vendor/craftcms/cms/src/web/Application.php(266): craft\web\Application->_processActionRequest(Object(craft\web\Request))
#20 /storage/av05091/www/public_html/releases/20180914110132/vendor/yiisoft/yii2/base/Application.php(386): craft\web\Application->handleRequest(Object(craft\web\Request))
#21 /storage/av05091/www/public_html/releases/20180914110132/public/index.php(46): yii\base\Application->run()
#22 {main}

Craft Pro 3.0.24
PHP 7.0.30
MySQL 5.7.23
Retour 3.0.4

[FR] Feature Request: Redirects from File/Config

I'd love to see a way of configuring the redirects in a file instead of through the database. Since the plugin already uses CSV for importing, maybe a setting to point to a CSV file with pre-populated redirects? Alternately assigning redirects via a Craft config file would work too.

Using global variables in redirect rules

We have 130 sites (city portals) in a multi-site configuration.
We did some URL changes to them and need to find a simple way of redirecting those.
We have parts of URLs for cities saved as Global Variables.

Can we use Global Variables in the plugin? (Tried with parameters but it didn't work.)
That would enable us to only have 1 redirect for something, instead of 130.

Thanks!

Upgrade league/csv to 9.x

The 8.x version isn't supported anymore, and I hope to upgrade Beam to 9.x at some point. Will have to downgrade it for now because it clashes with Retour and Feed Me.

PostgreSQL compatibility issues

Noticing some MySQL-specific SQL syntax in a couple places:

$stats = (new Query())
->from('{{%retour_stats}}')
->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )")
->count();
$handledStats = (new Query())
->from('{{%retour_stats}}')
->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )")
->andWhere('handledByRetour is TRUE')
->count();

CURDATE() isn’t supported by PostgreSQL, and hitLastTime and handledByRetour will be normalized to lowercase unless you wrap them in [[ and ]]s.

$stats = (new Query())
->from(['{{%retour_stats}}'])
->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )")
->andWhere("handledByRetour = {$handledInt}")
->orderBy('hitLastTime DESC')
->all();

CURDATE() and lowercase column name again. On line 85 instead of adding the brackets you could switch to

->andWhere(['handledByRetour' => $handledInt])

Possible to block one or more hostname(s) per redirect

Is there a way to block redirects from happening based on user defined host names when editing or adding redirects.

For example on a multi-site setup if you have two sites setup:

  • myotherdomain.com
  • mydomain.com

and a redirect setup as follows

  • /vhs redirecting to /netflix

That when hitting mydomain.com/vhs you do redirect but when hitting myotherdomain.com/vhs you don't redirect and it shows the 404. I imagine on more complex multi site setups this might be interesting.

This issue might coincide with #5 so i don't know if something such as this is already in the pipeline.

Full disclosure I basically figured out and developed a way to do this for this over at https://github.com/lemiwinkz/craft-retour/tree/develop so iam just curious if there is a native way for doing this that might be better or something is already being developed. If not i can submit a PR.

Invalid Argument Error on /admin/retour/settings

After installing via the plugin store and visiting the settings page, I get the following error:

yii\base\InvalidArgumentException: Invalid path alias: @nystudio107/seomatic/assetbundles/seomatic/dist in /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/BaseYii.php:154
Stack trace:
#0 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/web/AssetManager.php(449): yii\BaseYii::getAlias('@nystudio107/se...')
#1 /Users/tdavis/sites/hamilton/vendor/craftcms/cms/src/web/AssetManager.php(54): yii\web\AssetManager->publish('@nystudio107/se...')
#2 /Users/tdavis/sites/hamilton/vendor/nystudio107/craft-retour/src/controllers/SettingsController.php(74): craft\web\AssetManager->getPublishedUrl('@nystudio107/se...', true)
#3 [internal function]: nystudio107\retour\controllers\SettingsController->actionPluginSettings(Object(nystudio107\retour\models\Settings))
#4 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/base/InlineAction.php(57): call_user_func_array(Array, Array)
#5 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/base/Controller.php(157): yii\base\InlineAction->runWithParams(Array)
#6 /Users/tdavis/sites/hamilton/vendor/craftcms/cms/src/web/Controller.php(103): yii\base\Controller->runAction('plugin-settings', Array)
#7 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/base/Module.php(528): craft\web\Controller->runAction('plugin-settings', Array)
#8 /Users/tdavis/sites/hamilton/vendor/craftcms/cms/src/web/Application.php(282): yii\base\Module->runAction('retour/settings...', Array)
#9 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/web/Application.php(103): craft\web\Application->runAction('retour/settings...', Array)
#10 /Users/tdavis/sites/hamilton/vendor/craftcms/cms/src/web/Application.php(271): yii\web\Application->handleRequest(Object(craft\web\Request))
#11 /Users/tdavis/sites/hamilton/vendor/yiisoft/yii2/base/Application.php(386): craft\web\Application->handleRequest(Object(craft\web\Request))
#12 /Users/tdavis/sites/hamilton/public/index.php(46): yii\base\Application->run()
#13 {main}

Let me know if you need any other details, but seemed like just a copy and paste error that needed to be updated.

Fatal Error preg_match on unhandled 404s

Recently upgraded a site from Craft 2 to Craft 3, all worked on Craft 2 but Craft 3 version shows an error on an unhandled 404 page if it hasn't had a specific rule set up. On Craft 2 the 404 page would be shown by default instead of the internal server error page.

404 template: https://www.windermere-lakecruises.co.uk/404
unhandled 404: https://www.windermere-lakecruises.co.uk/testing
handled 404 redirected: https://www.windermere-lakecruises.co.uk/hire.php

A full stack trace of the error can be seen here: https://dev.windermere-lakecruises.co.uk/testing
Working Craft 2 functionality can be found here: https://old.windermere-lakecruises.co.uk/testing

PHP version | 7.1.22
Craft edition & version | Craft Pro 3.0.35
Retour version | 3.0.18

Please let me know if you need any further info. Thanks.

[FR] Add in the ability to do dynamic redirects

Since the Retour Field has been deprecated for the 3.0.0 version, replicate its functionality in a better way, by allowing for dynamic redirects.

The user can pick the section, and then it'll create redirects for every entry in that section, allowing for the use of key data like {{ entry.someKeyData }} in the redirect itself.

Doing it this way will be much nicer than the old Field method (which forced it to resave all of the entries if you made any changes).

Disable "Redirect To" link when there is a token in the destination url

I just got a frantic email from a client, concerned that when they clicked on the "redirect to" link in the Redirects list, it took them to a 404 page. As I eventually discovered, the problem is that it's a RegEx redirect, with a token in the destination URL. Of course, going to http://example.com/blog/$1 won't actually work. I figure it might be relatively simple to check for /\$\d+/ in the destination of a RegEx Match-type redirect, and in that case not create a link out of the destination to avoid confusion.

Retour capabilities

These are more questions than issues, so apologies if this isn't the right place to address them, but I'm considering moving from Wordpress to Craft and am wondering how the transition will affect the way we handle redirects.

  1. Is it possible to redirect to external urls? My company uses an external event platform that has really ugly urls, so we're in the habit of creating a shorter url that hits our Wordpress install and then sends users to the external event platform. I don't see any examples of this in your screenshots, so I wanted to confirm that this is/isn't a possibility.

  2. Would Retour be effective in a headless implementation of Craft? I'd love to implement Craft as a Vue application, but I'd need the same functionality mentioned above. My guess is the answer is no here, but I thought I'd ask.

Again, if this isn't the right place to ask these questions, my apologies. I just didn't see anywhere else on your site to do so. Thanks!

[FR] When changing a slug of an entry, add full url as a redirect

When i'm changing the slug of an entry it will save the path as a redirect. (e.g. /foo to /foo/bar with Path only)

The redirects aren't working because i have a site specific website and i'm missing my prefix in the path. What i expect to have is /a/foo to /a/foo/bar with Path only or the full url as a redirect.

CSV Import Errors

Getting an exception when trying to import redirects via .csv:
Exception: BadMethodCallException
Method: League\Csv\Reader::setOffset()
Ref: src/controllers/FileController.php:99

The installed CSV package has setHeaderOffset not setOffset. Tested that change, success .... however

src/controllers/FileController.php:101
$csv->each will begin to scream...

Waving the white flag...

Package information installed
Retour: "version": "3.1.5",
"source": {
"type": "git",
"url": "https://github.com/nystudio107/craft-retour.git",
"reference": "f31956852508fe8c37d354527d5b605403f8f1a8"
}

League CSV: "version": "9.1.4",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/csv.git",
"reference": "9c8ad06fb5d747c149875beb6133566c00eaa481"
}

Multisite - Permissions

On the dashboard and redirect page the multi site select box shows 'All sites' for user who have only access to 1 site. Another problem is when adding a redirect a user can see/select all sites from the select box. In both cases only the site the user have permissions for should be visible.

Dont add admin/retour/dashboard to "Last Referrer url"

If i click missing URL address from within retour dashbourd, website.com/admin/retour/dashboard appears in "last referrer URL column".
This infomation is not really usefull, I would rather know original referrer url :)

Automatic Redirects Don't Work on Multiple Sites

When the "Create Entry Redirects" setting is enabled, redirects are only created when an entry on the parent site is changed. If an entry slug is changed on a secondary site, no redirect is created.

Steps to Reproduce:

  1. Set up more than one site
  2. Enable "Create Entry Redirects" in Retour
  3. Switch to a non-primary site
  4. Update an entry's slug

Craft 3.1.9.1
Retour 3.1.6

Search fails with Postgres

Retour 3.0.9
Craft 3.0.26.1
PostgreSQL 9.6.6

On the CP Retour > Redirects page, entering a search term causes a server error. Server response for the request looks like this:

{"error":"SQLSTATE[42703]: Undefined column: 7 ERROR:  column \"redirectsrcurl\" does not exist\nLINE 3: WHERE (`redirectSrcUrl` LIKE '%eol%') OR (`redirectDestUrl` ...\n                ^\nHINT:  Perhaps you meant to reference the column \"retour_static_redirects.redirectSrcUrl\".\nThe SQL being executed was: SELECT *\nFROM \"retour_static_redirects\"\nWHERE (`redirectSrcUrl` LIKE '%eol%') OR (`redirectDestUrl` LIKE '%eol%')\nORDER BY \"hitCount\" DESC\nLIMIT 20"}

Looks like the column name needs the table prepended. So picky! :)

Adding Expires headers breaks retour

We've enabled nginx header expires via the conf, and it seems to break retour. All js/css 404s, I assume because they're created dynamically or something?

image

The nginx conf we're using for expires:

  location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp3|mp4|ogg|ogv|webm|htc|webp)$ {
        etag off;
        expires 1M;
        access_log off;
        add_header Cache-Control "public";
    }

Incorrect path for js and css files in admin panel

Tested on windows based xampp, with clean install of latest craft. Retour tries to load css and js files with wrong path. Result is retour pages appearing blank, without any functionality.

This is path retour tries to load use example file:
http://localhost/cpresources/retour/js/dashboard.66dfb269ddbef4138c0a.js

This is what it should look like:
http://localhost/craft_install/web/cpresources/retour/js/dashboard.66dfb269ddbef4138c0a.js

My system info:

PHP version 7.2.2
Database driver & version MySQL 5.5.5
Image driver & version GD 7.2.2
Craft edition & version Craft Solo 3.0.24
Yii version 2.0.15.1
Twig version 2.5.0
Guzzle version 6.3.3
Imagine version 0.7-dev

[FR] Change existing redirect whose destination matches new redirect's legacy URL

Okay, sorry about the confusing title, but here are some "repro steps" to explain what I mean:

  1. Change the URI of an entry from entry-1 to cool-entry-1
  2. Notice it adds a redirect in Retour, cool
  3. Change the URI again from cool-entry-1 to awesome-entry-1
  4. Notice it adds another redirect in Retour, so now we have:
  • /entry-1 => /cool-entry-1
  • /cool-entry-1 => /awesome-entry-1

It would be great if Retour was smarter about it, and also changed the existing redirect, so we'd have:

  • /entry-1 => /awesome-entry-1
  • /cool-entry-1 => /awesome-entry-1

...and there would only be one redirection when going to /entry-1. Does that make sense?

Thanks a lot for the great plugin <3

[FR] Clear “Handled” stats only

Just a thought, but when the critical mass of results/items hits 1000 or whatever the maximum number has been set to, it might be helpful to clear only the stats that have been marked as handled. This way, if you are in the middle of a huge migration as I am, you can pay attention to unresolved items that might have fewer hits and be less likely to show up again in the immediate future.

Make redirects available in elements-api

Is it possible to user or access redirects from the elements-api? I'm using the elements-api to fetch Entries by uri, for example:

return [
	'elementType' => Entry::class,
	'criteria' => [
		'uri' => 'page/' . $entrySlug,
		'site' => $siteHandle,
	],
	...

Somewhere I'd expect the retour plugin to jump in here and 'fix' the uri when an old uri is used. But on the other hand I understand that it might be difficult to support a plugin within a plugin.

Is there some way I can fix this manually by using the stored redirects?

Make the CP pages not be ugly

Apply fancy CSS styling to make the Dashboard / Redirects pages look nicer (especially the Search and Pagination)

Redirects stop at 1000?

We installed the plugin yesterday (what a godsend, btw) but we noticed that our results are capping at 1000 items under the dashboard. Is this by design? I can't imagine we've stopped at exactly 1k results.

Data not available in Chrome browser after 6 pages of redirects

Hello,
I am on a project redoing a large website. Yesterday I was on the 6th page of redirects and after saving a new static redirect, I was unable to see any of my previously saved redirects. The table says "No Data Available." When I open up the admin panel in incognito mode, I am sometimes able to see the data and sometimes not. I have rebooted the admin panel, the chrome browser, and the whole computer. Any help would be appreciated.

Importing CSVs only renders one redirect

I'm only getting one redirect (the first one in the list) when I import a CSV. I've made sure I match the field values and still have no luck. I've attached the file I'm using for testing.

404s.zip

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.