Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add webpack plugin #526

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,17 @@ const Ziggy = {
export { Ziggy };
```

You can optionally create a webpack alias to make importing Ziggy's core source files easier:
You can optionally create a webpack alias and use our webpack plugin to make generating and importing Ziggy's core source files easier:

```js
// webpack.mix.js
const {exec} = require('child_process');

// Webpack plugin
const ZiggyWebpackPlugin = require('./vendor/tightenco/ziggy/webpackPlugin');
mix.webpackConfig({
plugins: [new ZiggyWebpackPlugin('php artisan ziggy:generate')],
});

// Mix v6
const path = require('path');
Expand Down
912 changes: 910 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
],
"files": [
"src/js",
"dist"
"dist",
"webpackPlugin.js"
],
"source": "src/js/index.js",
"main": "dist/index.js",
Expand Down Expand Up @@ -54,10 +55,14 @@
"dependencies": {
"qs": "~6.9.7"
},
"optionalDependencies": {
"webpack": "^5"
},
"devDependencies": {
"babel-preset-power-assert": "^3.0.0",
"jest": "^27.0.6",
"jest": "^27.5.1",
"microbundle": "^0.14.2",
"power-assert": "^1.6.1"
"power-assert": "^1.6.1",
"webpack": "^5.70.0"
}
}
14 changes: 10 additions & 4 deletions src/CommandRouteGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace Tightenco\Ziggy;

use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Filesystem\Filesystem;
use Tightenco\Ziggy\Output\File;
use Tightenco\Ziggy\Ziggy;

class CommandRouteGenerator extends Command
{
Expand All @@ -31,9 +31,15 @@ public function handle()
$generatedRoutes = $this->generate($this->option('group'));

$this->makeDirectory($path);
$this->files->put(base_path($path), $generatedRoutes);

$this->info('File generated!');
try {
$prevContent = $this->files->get(base_path($path));
} catch (FileNotFoundException $e) {
$prevContent = '';
}
if ($prevContent != $generatedRoutes) {
$this->files->put(base_path($path), $generatedRoutes);
$this->info('File '.$path.' generated!');
}
}

protected function makeDirectory($path)
Expand Down
21 changes: 21 additions & 0 deletions tests/Unit/CommandRouteGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
use Tightenco\Ziggy\Output\File;
use Tightenco\Ziggy\Ziggy;

class CommandRouteGeneratorTest extends TestCase
{
Expand Down Expand Up @@ -131,6 +132,26 @@ public function can_generate_file_for_specific_configured_route_group()

$this->assertFileEquals('./tests/fixtures/admin.js', base_path('resources/js/admin.js'));
}

/** @test */
public function doesnt_touch_file_if_not_modified() {
Artisan::call('ziggy:generate');
$time = filemtime(base_path('resources/js/ziggy.js'));
sleep(1);

Ziggy::clearRoutes();
Artisan::call('ziggy:generate');
$this->assertEquals($time, filemtime(base_path('resources/js/ziggy.js')));

$router = app('router');
$router->get('posts/{post}/comments', $this->noop())->name('postComments.index');
$router->get('slashes/{slug}', $this->noop())->where('slug', '.*')->name('slashes');
$router->getRoutes()->refreshNameLookups();

Ziggy::clearRoutes();
Artisan::call('ziggy:generate');
$this->assertNotEquals($time, filemtime(base_path('resources/js/ziggy.js')));
}
}

class CustomFileFormatter extends File
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Entry file for the tests of the webpack plugin
import route from '../../src/js';
import routes from './ziggy';

route('postComments.index', 1, true, routes);
142 changes: 142 additions & 0 deletions tests/js/webpackPlugin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import webpack from 'webpack';

import ZiggyWebpackPlugin from '../../webpackPlugin';

const fs = require('fs');
const path = require('path');

let compiler;
const make = (command, options) => {
compiler = webpack({
mode: 'none',
output: {
path: path.resolve(__dirname, '../dist'),
},
watchOptions: {
aggregateTimeout: 0,
},
entry: path.resolve(__dirname, '../fixtures/entry.js'),
plugins: [new ZiggyWebpackPlugin(command, { path: 'vendor/orchestra/testbench-core/laravel', ...options })],
});
return compiler;
};

const consoleError = console.error;
const consoleLog = console.log;
const mockConsole = () => {
console.log = jest.fn();
console.error = jest.fn();
};

const restoreConsole = () => {
console.log = consoleLog;
console.error = consoleError;
};

const mockAsync = () => jest.fn(() => new Promise(resolve => setTimeout(resolve, 10)));

beforeEach(() => {
mockConsole();
});

afterEach((done) => {
if (compiler) {
compiler.close((closeErr) => {
console.log('closed')
done(closeErr);
});
} else {
done();
}

restoreConsole();
});

afterAll(() => {
// Cleanup files
try {
fs.rmdirSync(path.resolve(__dirname, '../dist'), { recursive: true });
} catch (e) {
console.error(e);
}
try {
fs.rmSync(path.resolve(__dirname, '../fixtures/watch.txt'));
} catch (e) {
console.error(e);
}
})

describe('webpack plugin', () => {
test('compiles before run', () => new Promise((resolve, reject) => {
const fn = mockAsync();
make((...a) => fn.apply(null, a)) // fn instanceof Function is false, so wrap it
.run((err, stats) => {
if (err) {
return reject(err);
} else if (stats.hasErrors()) {
return reject(stats.toString());
}
expect(fn).toHaveBeenCalledTimes(1);
resolve();
});
}));

test('run command redirects output', () => new Promise((resolve, reject) => {
make('echo "ok" && >&2 echo "error"')
.run((err, stats) => {
if (err) {
return reject(err);
} else if (stats.hasErrors()) {
return reject(stats.toString());
}
expect(console.log).toHaveBeenCalledWith('ok\n');
expect(console.error).toHaveBeenCalledWith('error\n');
resolve();
});
}));

test('watches for change', () => {
restoreConsole();
const prev = fs.readFileSync(path.resolve(__dirname, '../fixtures/ziggy.js'));
return new Promise((resolve, reject) => {
const watch = path.resolve(__dirname, '../fixtures/watch.txt');
fs.writeFileSync(watch, 'test1');
const filesCb = jest.fn((files) => {
expect(files).toContain(path.resolve(__dirname, '../../vendor/orchestra/testbench-core/laravel/config/ziggy.php'));
files.push(watch);
return files;
});
let run = 1;
const fn = mockAsync();
make((...a) => fn.apply(null, a), { filesToWatchCallback: (...a) => filesCb.apply(null, a) });
const emitted = jest.fn();
compiler.hooks.emit.tap('Test', emitted);
compiler = compiler.watch({}, (err, stats) => {
if (err) {
return reject(err);
} else if (stats.hasErrors()) {
return reject(stats.toString());
}

switch (run) {
case 1:
expect(emitted).toHaveBeenCalledTimes(1); // It should have been called once on initial compilation
expect(fn).toHaveBeenCalledTimes(1);
fs.promises.writeFile(watch, 'test2').catch(reject);
setTimeout(() => {
expect(fn).toHaveBeenCalledTimes(2);
// It shouldn't have triggered a rebuild because watched file is not actually required by our entry.js
expect(emitted).toHaveBeenCalledTimes(1);
fs.promises.appendFile(path.resolve(__dirname, '../fixtures/ziggy.js'), 'console.log("test");').catch(reject);
}, 100);
break;
default:
expect(emitted).toHaveBeenCalledTimes(run);
resolve();
break;
}
run++;
});
}).then(() => fs.writeFileSync(path.resolve(__dirname, '../fixtures/ziggy.js'), prev));
});
});
100 changes: 100 additions & 0 deletions webpackPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');

// Those are both webpack dependencies
const validate = require('schema-utils').validate || require('schema-utils'); // In a previous version it was exported as default
const Watchpack = require('watchpack');

module.exports = class ZiggyWebpackPlugin {
/**
*
* @param {string|function} command
* @param {{path: string, filesToWatchCallback: function}} [options]
*/
constructor(command, options = {}) {
validate({
anyOf: [
{ type: 'string' },
{ instanceof: 'Function' },
],
description: 'The command or async callback to run the compilation',
}, command, {
name: 'Ziggy Webpack Plugin',
baseDataPath: 'command',
});
validate({
type: 'object',
properties: {
path: {
description: 'This is the base path, where laravel is installed',
type: 'string',
},
filesToWatchCallback: {
description: 'If you need to change the list of watched files',
instanceof: 'Function',
},
},
additionalProperties: false,
}, options, {
name: 'Ziggy Webpack Plugin',
baseDataPath: 'options',
});
this.command = command;
this.path = options.path || './';
this.filesToWatchCallback = (options.filesToWatchCallback || (files => files));
}

apply(compiler) {
// Create a separate watcher
compiler.hooks.watchRun.tapPromise('ZiggyWebpackPlugin', () => {
if (!this._init) {
this._init = true;
this.watcher = new Watchpack({ aggregateTimeout: 50, ...compiler.watching?.watchOptions });

let toWatch = [path.resolve(this.path, './routes/'), path.resolve(this.path, './config/ziggy.php')];
toWatch = this.filesToWatchCallback(toWatch) || toWatch;

// Run the generation command which in turn will trigger a rebuild if needed
this.watcher.on('aggregated', () => this._runCommand());

Promise.all(toWatch.map((file) => fs.promises.lstat(file).catch(() => null))).then((stats) => {
const directories = [];
const files = [];
const missing = [];
stats.forEach((stat, index) => (stat ? (stat.isDirectory() ? directories : files) : missing).push(toWatch[index]));

this.watcher.watch({ files, directories, missing });
});
// Run the generation command before webpack compilation
return this._runCommand();
} else {
return Promise.resolve();
}
});
compiler.hooks.watchClose.tap('ZiggyWebpackPlugin', () => this.watcher.close());

// Run the generation command before webpack compilation when we're not watching
compiler.hooks.run.tapPromise('ZiggyWebpackPlugin', () => this._runCommand());
}

_runCommand() {
return typeof this.command === 'function' ? Promise.resolve(this.command()) :
new Promise((resolve, reject) => {
const process = exec(this.command);
process.stdout.on('data', (data) => {
console.log(data);
});
process.stderr.on('data', (data) => {
console.error(data);
});
process.on('exit', code => {
if (code !== 0) {
reject();
} else {
resolve();
}
});
});
}
};