mirror of
https://github.com/okdana/twigc.git
synced 2024-05-21 14:56:35 +02:00
499 lines
14 KiB
PHP
499 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* This file is part of twigc.
|
|
*
|
|
* @author dana <dana@dana.is>
|
|
* @license MIT
|
|
*/
|
|
|
|
namespace Dana\Twigc;
|
|
|
|
use GetOpt\{Argument,GetOpt,Operand,Option};
|
|
use Symfony\Component\Console\Helper\Table;
|
|
use Symfony\Component\Console\Output\{ConsoleOutputInterface,OutputInterface};
|
|
use Twig\Environment;
|
|
use Twig\Extension\EscaperExtension;
|
|
use Twig\Loader\{ArrayLoader,FilesystemLoader};
|
|
|
|
use Dana\Twigc\ComposerHelper;
|
|
|
|
/**
|
|
* This class represents the entire `twigc` application.
|
|
*
|
|
* To be completely honest, this feels really shitty to me, and i don't like it.
|
|
* But after eliminating the standard Symfony\Console structure (due to
|
|
* Console's woefully inadequate argument handling, amongst other things), i
|
|
* find myself unsure of the best way to structure this, especially given how
|
|
* simple the application is, and just kind of want to be done with it. I guess
|
|
* this works for now, but i would welcome any improvements.
|
|
*/
|
|
class Application {
|
|
const NAME = 'twigc';
|
|
const VERSION = '0.3.1';
|
|
const BUILD_DATE = '%BUILD_DATE%'; // Replaced during build
|
|
|
|
protected $name;
|
|
protected $version;
|
|
|
|
/**
|
|
* Construct the object.
|
|
*
|
|
* @param string|null $name
|
|
* (optional) The name of the application, to be used in error messages and
|
|
* the like.
|
|
*
|
|
* @param string|null $version
|
|
* (optional) The version number of the application, to be used in the
|
|
* `--version` output.
|
|
*
|
|
* @return self
|
|
*/
|
|
public function __construct(string $name = null, string $version = null) {
|
|
$this->name = $name ?? static::NAME;
|
|
$this->version = $version ?? static::VERSION;
|
|
}
|
|
|
|
/**
|
|
* Run the application.
|
|
*
|
|
* This is mostly a wrapper around doRun() to handle error printing.
|
|
*
|
|
* @param OutputInterface $output
|
|
* The output to write to.
|
|
*
|
|
* @param array|null $argv
|
|
* (optional) Command-line arguments to the application (with the 0th member
|
|
* as the application name).
|
|
*
|
|
* @return int
|
|
*/
|
|
public function run(OutputInterface $output, array $argv = null): int {
|
|
if ( $output instanceof ConsoleOutputInterface ) {
|
|
$error = $output->getErrorOutput();
|
|
} else {
|
|
$error = $output;
|
|
}
|
|
|
|
try {
|
|
return $this->doRun($output, $argv);
|
|
} catch ( \Exception $e ) {
|
|
$error->writeln(sprintf(
|
|
'%s: %s', $this->name,
|
|
rtrim($e->getMessage(), "\r\n"))
|
|
);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the application (for real).
|
|
*
|
|
* @param OutputInterface $output
|
|
* The output to write to.
|
|
*
|
|
* @param array|null $argv
|
|
* (optional) Command-line arguments to the application (with the 0th member
|
|
* as the application name).
|
|
*
|
|
* @return int
|
|
*/
|
|
public function doRun(OutputInterface $output, array $argv = null): int {
|
|
$argv = $argv ?? $_SERVER['argv'];
|
|
$getopt = $this->getGetOpt();
|
|
|
|
$getopt->process(array_slice($argv, 1));
|
|
|
|
if ( $getopt->getOption('help') ) {
|
|
$this->doHelp($output, $getopt);
|
|
return 0;
|
|
}
|
|
if ( $getopt->getOption('version') ) {
|
|
$this->doVersion($output);
|
|
return 0;
|
|
}
|
|
if ( $getopt->getOption('credits') ) {
|
|
$this->doCredits($output);
|
|
return 0;
|
|
}
|
|
|
|
$inputData = [];
|
|
$template = $getopt->getOperand('template');
|
|
$dirs = $getopt->getOption('dir');
|
|
$temp = false;
|
|
|
|
// If we're receiving data on standard input, and we didn't get a template,
|
|
// assume `-` — we'll make sure this doesn't conflict with `-j` below
|
|
if ( ! posix_isatty(\STDIN) ) {
|
|
$template = $template ?? '-';
|
|
}
|
|
|
|
// Add the template's parent directory if we're not using standard input
|
|
if ( ($template ?? '-') !== '-' ) {
|
|
$dirs = array_merge([dirname($template)], $dirs);
|
|
}
|
|
|
|
if ( $template === null ) {
|
|
$this->doHelp($output, $getopt, 'No template specified');
|
|
return 1;
|
|
}
|
|
|
|
// Input data via environment
|
|
if ( $getopt->getOption('env') ) {
|
|
if ( empty($_ENV) && strpos(ini_get('variables_order'), 'E') === false ) {
|
|
throw new \RuntimeException(
|
|
"INI setting 'variables_order' must include 'E' to use option 'env'"
|
|
);
|
|
}
|
|
$inputData = array_merge($inputData, $_ENV);
|
|
}
|
|
|
|
// Input data via query string
|
|
foreach ( $getopt->getOption('query') as $query ) {
|
|
$query = ltrim($query, '?');
|
|
$parsed = [];
|
|
|
|
parse_str($query, $parsed);
|
|
|
|
$inputData = array_merge($inputData, $parsed);
|
|
}
|
|
|
|
// Input data via JSON
|
|
foreach ( $getopt->getOption('json') as $json ) {
|
|
// JSON supplied via standard input
|
|
if ( $json === '-' ) {
|
|
if ( $template === '-' ) {
|
|
throw new \InvalidArgumentException(
|
|
'Can not read both template and JSON input from stdin'
|
|
);
|
|
}
|
|
|
|
$json = file_get_contents('php://stdin');
|
|
|
|
// JSON supplied via file
|
|
} elseif ( (ltrim($json)[0] ?? '') !== '{' ) {
|
|
if ( ! file_exists($json) || is_dir($json) ) {
|
|
throw new \InvalidArgumentException(
|
|
"Missing or invalid JSON file: ${json}"
|
|
);
|
|
}
|
|
$json = file_get_contents($json);
|
|
}
|
|
|
|
// This check is here to prevent errors if the input is just empty
|
|
if ( trim($json) !== '' ) {
|
|
$json = json_decode($json, true);
|
|
}
|
|
|
|
if ( ! is_array($json) ) {
|
|
throw new \InvalidArgumentException(
|
|
'JSON input must be a dictionary'
|
|
);
|
|
}
|
|
|
|
$inputData = array_merge($inputData, $json);
|
|
}
|
|
|
|
// Input data via key=value pair
|
|
foreach ( $getopt->getOption('pair') as $pair ) {
|
|
$kv = explode('=', $pair, 2);
|
|
|
|
if ( count($kv) !== 2 ) {
|
|
throw new \InvalidArgumentException(
|
|
"Illegal key=value pair: ${pair}"
|
|
);
|
|
}
|
|
|
|
$inputData[$kv[0]] = $kv[1];
|
|
}
|
|
|
|
// Template supplied via file path
|
|
if ( $template !== '-' ) {
|
|
$loader = new FilesystemLoader($dirs);
|
|
// Template supplied via standard input
|
|
} else {
|
|
// If we've been supplied one or more search directories, we'll need to
|
|
// write the template out to a temp directory so we can use the file-
|
|
// system loader
|
|
if ( $dirs ) {
|
|
$temp = true;
|
|
$template = implode('/', [
|
|
sys_get_temp_dir(),
|
|
implode('.', ['', $this->name, getmypid(), md5(time())]),
|
|
$template,
|
|
]);
|
|
|
|
mkdir(dirname($template));
|
|
file_put_contents($template, file_get_contents('php://stdin'), \LOCK_EX);
|
|
|
|
$dirs = array_merge([dirname($template)], $dirs);
|
|
$loader = new FilesystemLoader($dirs);
|
|
|
|
// Otherwise, we can just use the array loader, which is a little faster
|
|
// and cleaner
|
|
} else {
|
|
$loader = new ArrayLoader([
|
|
$template => file_get_contents('php://stdin'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Render
|
|
try {
|
|
$twig = new Environment($loader, [
|
|
'cache' => $getopt->getOption('cache') ?? false,
|
|
'debug' => false,
|
|
'strict_variables' => (bool) $getopt->getOption('strict'),
|
|
'autoescape' => $this->getEscaper(
|
|
$getopt->getOption('escape'),
|
|
$template
|
|
),
|
|
]);
|
|
|
|
$twig->getExtension(EscaperExtension::class)->setEscaper(
|
|
'json',
|
|
function ($twigEnv, $string, $charset) {
|
|
return json_encode(
|
|
$string,
|
|
\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
|
|
);
|
|
}
|
|
);
|
|
$twig->getExtension(EscaperExtension::class)->setEscaper(
|
|
'sh',
|
|
function ($twigEnv, $string, $charset) {
|
|
return '"' . addcslashes($string, '$`\\"') . '"';
|
|
}
|
|
);
|
|
|
|
$output->writeln(
|
|
rtrim($twig->render(basename($template), $inputData), "\r\n")
|
|
);
|
|
|
|
// Clean up
|
|
} finally {
|
|
if ( $temp ) {
|
|
unlink($template);
|
|
rmdir(dirname($template));
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Display the application's usage help.
|
|
*
|
|
* @param OutputInterface $output
|
|
* The output to write to.
|
|
*
|
|
* @param GetOpt $getopt
|
|
* The GetOpt instance from which to derive the usage help.
|
|
*
|
|
* @param string|null $message
|
|
* (optional) An additional message to print above the usage help. This is
|
|
* intended primarily for error messages.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function doHelp(
|
|
OutputInterface $output,
|
|
GetOpt $getopt,
|
|
string $message = null
|
|
): int {
|
|
if ( $message !== null && $message !== '' ) {
|
|
$output->writeln("{$this->name}: " . rtrim($message, "\r\n") . "\n");
|
|
}
|
|
$output->writeln(rtrim($getopt->getHelpText(), "\r\n"));
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Display the application's version information.
|
|
*
|
|
* @param OutputInterface $output The output to write to.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function doVersion(OutputInterface $output): int {
|
|
$version = sprintf('%s version %s', $this->name, $this->version);
|
|
|
|
if ( strpos(static::BUILD_DATE, '%') === false ) {
|
|
$version .= sprintf(' (built %s)', static::BUILD_DATE);
|
|
}
|
|
|
|
$output->writeln($version);
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Display the application's dependency information.
|
|
*
|
|
* @param OutputInterface $output The output to write to.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function doCredits(OutputInterface $output): int {
|
|
$packages = (new ComposerHelper())->getPackages();
|
|
|
|
$table = new Table($output);
|
|
$table->setStyle('compact');
|
|
$table->getStyle()->setVerticalBorderChars('');
|
|
$table->getStyle()->setCellRowContentFormat('%s ');
|
|
$table->setHeaders(['#name', 'version', 'licence']);
|
|
|
|
foreach ( $packages as $package ) {
|
|
$table->addRow([
|
|
$package->name,
|
|
ltrim($package->version, 'v'),
|
|
implode(', ', $package->license) ?: '?',
|
|
]);
|
|
}
|
|
$table->render();
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Get an instance of GetOpt configured for this application.
|
|
*
|
|
* @return GetOpt
|
|
*/
|
|
public function getGetOpt(): GetOpt {
|
|
$getopt = new GetOpt(null, [
|
|
GetOpt::SETTING_SCRIPT_NAME => $this->name,
|
|
GetOpt::SETTING_STRICT_OPERANDS => true,
|
|
]);
|
|
$getopt->addOptions([
|
|
Option::create('h', 'help', GetOpt::NO_ARGUMENT)
|
|
->setDescription('Display this usage help and exit')
|
|
,
|
|
Option::create('V', 'version', GetOpt::NO_ARGUMENT)
|
|
->setDescription('Display version information and exit')
|
|
,
|
|
Option::create(null, 'credits', GetOpt::NO_ARGUMENT)
|
|
->setDescription('Display dependency information and exit')
|
|
,
|
|
Option::create(null, 'cache', GetOpt::REQUIRED_ARGUMENT)
|
|
->setDescription('Enable caching to specified directory')
|
|
->setArgumentName('dir')
|
|
->setValidation('is_dir')
|
|
,
|
|
Option::create('d', 'dir', GetOpt::MULTIPLE_ARGUMENT)
|
|
->setDescription('Add specified search directory to loader')
|
|
->setArgumentName('dir')
|
|
->setValidation('is_dir')
|
|
,
|
|
Option::create('e', 'escape', GetOpt::REQUIRED_ARGUMENT)
|
|
->setArgumentName('strategy')
|
|
->setDescription('Specify default auto-escaping strategy')
|
|
,
|
|
Option::create('E', 'env', GetOpt::NO_ARGUMENT)
|
|
->setDescription('Derive input data from environment')
|
|
,
|
|
Option::create('j', 'json', GetOpt::MULTIPLE_ARGUMENT)
|
|
->setArgumentName('dict/file')
|
|
->setDescription('Derive input data from specified JSON file or dictionary string')
|
|
,
|
|
Option::create('p', 'pair', GetOpt::MULTIPLE_ARGUMENT)
|
|
->setArgumentName('input')
|
|
->setDescription('Derive input data from specified key=value pair')
|
|
,
|
|
Option::create(null, 'query', GetOpt::MULTIPLE_ARGUMENT)
|
|
->setArgumentName('input')
|
|
->setDescription('Derive input data from specified URL query string')
|
|
,
|
|
Option::create('s', 'strict', GetOpt::NO_ARGUMENT)
|
|
->setDescription('Throw exception when undefined variable is referenced')
|
|
,
|
|
]);
|
|
$getopt->addOperands([
|
|
Operand::create('template', Operand::OPTIONAL),
|
|
]);
|
|
|
|
return $getopt;
|
|
}
|
|
|
|
/**
|
|
* Get the correct Twig escape method given the provided options.
|
|
*
|
|
* @param string|null $escape
|
|
* The user-provided escape option, or null if not provided.
|
|
*
|
|
* @param string|null $template
|
|
* The name/path of the template file, or null if not provided. This is only
|
|
* used when $escape is null or 'auto'.
|
|
*
|
|
* @return string|false
|
|
*/
|
|
public function getEscaper($escape, string $template = null) {
|
|
$escape = $escape === null ? $escape : strtolower($escape);
|
|
$template = $template ?? '';
|
|
|
|
if ( $escape === null || $escape === 'auto' ) {
|
|
if (
|
|
substr($template, -5) === '.twig'
|
|
&&
|
|
strpos(substr($template, 0, -5), '.')
|
|
) {
|
|
$ext = pathinfo(substr($template, 0, -5), \PATHINFO_EXTENSION);
|
|
} else {
|
|
$ext = pathinfo($template, \PATHINFO_EXTENSION);
|
|
}
|
|
|
|
switch ( strtolower($ext) ) {
|
|
case 'htm':
|
|
case 'html':
|
|
case 'phtml':
|
|
case 'thtml':
|
|
case 'xhtml':
|
|
case 'template':
|
|
case 'tmpl':
|
|
case 'tpl':
|
|
return 'html';
|
|
case 'css':
|
|
case 'scss':
|
|
return 'css';
|
|
case 'js':
|
|
return 'js';
|
|
case 'json':
|
|
return 'json';
|
|
case 'bash':
|
|
case 'ksh':
|
|
case 'sh':
|
|
case 'zsh':
|
|
return 'sh';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Otherwise, try to parse the supplied method
|
|
switch ( $escape ) {
|
|
case 'f':
|
|
case 'n':
|
|
case 'none':
|
|
case 'never':
|
|
$escape = 'false';
|
|
break;
|
|
case 't':
|
|
case 'y':
|
|
case 'always':
|
|
$escape = 'true';
|
|
break;
|
|
}
|
|
|
|
$bool = filter_var(
|
|
$escape,
|
|
\FILTER_VALIDATE_BOOLEAN,
|
|
\FILTER_NULL_ON_FAILURE
|
|
);
|
|
|
|
if ( $bool !== null ) {
|
|
$escape = $bool ? 'html' : false;
|
|
}
|
|
|
|
return $escape;
|
|
}
|
|
}
|