Unverified Commit f0462354 authored by lauriii's avatar lauriii

Issue #3064854 by markcarver, andypost, johnwebdev, lauriii, ridhimaabrol24,...

Issue #3064854 by markcarver, andypost, johnwebdev, lauriii, ridhimaabrol24, Hardik_Patel_12, Charlie ChX Negyesi, jhodgdon, aleksip, alexpott, Fabianx, Chi, Wim Leers: Allow Twig templates to use front matter for metadata support
parent ba28c93e
......@@ -486,7 +486,7 @@
"dist": {
"type": "path",
"url": "core",
"reference": "5bd6798a64831fa08a343a14a0ee47127c4cb99f"
"reference": "deeb3ec5ad5b0a9b0aa65505ab74e2bde5255abd"
},
"require": {
"asm89/stack-cors": "^1.1",
......
......@@ -85,6 +85,7 @@
"drupal/core-file-cache": "self.version",
"drupal/core-file-security": "self.version",
"drupal/core-filesystem": "self.version",
"drupal/core-front-matter": "self.version",
"drupal/core-gettext": "self.version",
"drupal/core-graph": "self.version",
"drupal/core-http-foundation": "self.version",
......
<?php
namespace Drupal\Component\FrontMatter\Exception;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
/**
* Defines a class for front matter parsing exceptions.
*/
class FrontMatterParseException extends InvalidDataTypeException {
/**
* The line number of where the parse error occurred.
*
* This line number is in relation to where the parse error occurred in the
* source front matter content. It is different from \Exception::getLine()
* which is populated with the line number of where this exception was
* thrown in PHP.
*
* @var int
*/
protected $sourceLine;
/**
* Constructs a new FrontMatterParseException instance.
*
* @param \Drupal\Component\Serialization\Exception\InvalidDataTypeException $exception
* The exception thrown when attempting to parse front matter data.
*/
public function __construct(InvalidDataTypeException $exception) {
$this->sourceLine = 1;
// Attempt to extract the line number from the serializer error. This isn't
// a very stable way to do this, however it is the only way given that
// \Drupal\Component\Serialization\SerializationInterface does not have
// methods for accessing this kind of information reliably.
$message = 'An error occurred when attempting to parse front matter data';
if ($exception) {
preg_match('/line:?\s?(\d+)/i', $exception->getMessage(), $matches);
if (!empty($matches[1])) {
$message .= ' on line %d';
// Add any matching line count to the existing source line so it
// increases it by 1 to account for the front matter separator (---).
$this->sourceLine += (int) $matches[1];
}
}
parent::__construct(sprintf($message, $this->sourceLine), 0, $exception);
}
/**
* Retrieves the line number where the parse error occurred.
*
* This line number is in relation to where the parse error occurred in the
* source front matter content. It is different from \Exception::getLine()
* which is populated with the line number of where this exception was
* thrown in PHP.
*
* @return int
* The source line number.
*/
public function getSourceLine(): int {
return $this->sourceLine;
}
}
<?php
namespace Drupal\Component\FrontMatter;
use Drupal\Component\FrontMatter\Exception\FrontMatterParseException;
use Drupal\Component\Serialization\Exception\InvalidDataTypeException;
use Drupal\Component\Serialization\SerializationInterface;
/**
* Component for parsing front matter from a source.
*
* This component allows for an easy and convenient way to parse
* @link https://jekyllrb.com/docs/front-matter/ front matter @endlink
* from a source.
*
* Front matter is used as a way to provide additional static data associated
* with a source without affecting the contents of the source. Typically this
* is used in templates to denote special handling or categorization.
*
* Front matter must be the first thing in the source and must take the form of
* valid YAML set in between triple-hyphen lines:
*
* source.md:
* @code
* ---
* important: true
* ---
* My content
* @endcode
*
* example.php:
* @code
* use Drupal\Component\FrontMatter\FrontMatter;
*
* $frontMatter = FrontMatter::create(file_get_contents('source.md'));
* $data = $frontMatter->getData(); // ['important' => TRUE]
* $content = $frontMatter->getContent(); // 'My content'
* $line => $frontMatter->getLine(); // 4, line where content actually starts.
* @endcode
*
* @ingroup utility
*/
class FrontMatter {
/**
* The separator used to indicate front matter data.
*
* @var string
*/
const SEPARATOR = '---';
/**
* The regular expression used to extract the YAML front matter content.
*
* @var string
*/
const REGEXP = '/\A(' . self::SEPARATOR . '(.*?)?\R' . self::SEPARATOR . ')(\R.*)?\Z/s';
/**
* The parsed source.
*
* @var array
*/
protected $parsed;
/**
* A serializer.
*
* @var string
*/
protected $serializer;
/**
* The source.
*
* @var string
*/
protected $source;
/**
* FrontMatter constructor.
*
* @param string $source
* A string source.
* @param string $serializer
* The name of a class that implements
* \Drupal\Component\Serialization\SerializationInterface.
*/
public function __construct(string $source, string $serializer = '\Drupal\Component\Serialization\Yaml') {
assert(is_subclass_of($serializer, SerializationInterface::class), sprintf('The $serializer parameter must reference a class that implements %s.', SerializationInterface::class));
$this->serializer = $serializer;
$this->source = $source;
}
/**
* Creates a new FrontMatter instance.
*
* @param string $source
* A string source.
* @param string $serializer
* The name of a class that implements
* \Drupal\Component\Serialization\SerializationInterface.
*
* @return static
*/
public static function create(string $source, string $serializer = '\Drupal\Component\Serialization\Yaml') {
return new static($source, $serializer);
}
/**
* Parses the source.
*
* @return array
* An associative array containing:
* - content: The real content.
* - data: The front matter data extracted and decoded.
* - line: The line number where the real content starts.
*
* @throws \Drupal\Component\FrontMatter\Exception\FrontMatterParseException
*/
protected function parse(): array {
if (!$this->parsed) {
$content = $this->source;
$data = [];
$line = 1;
// Parse front matter data.
if (preg_match(static::REGEXP, $content, $matches)) {
// Extract the source content.
$content = !empty($matches[3]) ? trim($matches[3]) : '';
// Extract the front matter data and typecast to an array to ensure
// top level scalars are in an array.
$raw = !empty($matches[2]) ? trim($matches[2]) : '';
if ($raw) {
try {
$data = (array) $this->serializer::decode($raw);
}
catch (InvalidDataTypeException $exception) {
// Rethrow a specific front matter parse exception.
throw new FrontMatterParseException($exception);
}
}
// Determine the real source line by counting all newlines in the first
// match (which includes the front matter separators) and append a new
// line to denote that the content should start after it.
if (!empty($matches[1])) {
$line += preg_match_all('/\R/', $matches[1] . "\n");
}
}
// Set the parsed data.
$this->parsed = [
'content' => $content,
'data' => $data,
'line' => $line,
];
}
return $this->parsed;
}
/**
* Retrieves the extracted source content.
*
* @return string
* The extracted source content.
*
* @throws \Drupal\Component\FrontMatter\Exception\FrontMatterParseException
*/
public function getContent(): string {
return $this->parse()['content'];
}
/**
* Retrieves the extracted front matter data.
*
* @return array
* The extracted front matter data.
*
* @throws \Drupal\Component\FrontMatter\Exception\FrontMatterParseException
*/
public function getData(): array {
return $this->parse()['data'];
}
/**
* Retrieves the line where the source content starts, after any data.
*
* @return int
* The source content line.
*
* @throws \Drupal\Component\FrontMatter\Exception\FrontMatterParseException
*/
public function getLine(): int {
return $this->parse()['line'];
}
}
This diff is collapsed.
The Drupal FrontMatter Component
Thanks for using this Drupal component.
You can participate in its development on Drupal.org, through our issue system:
https://www.drupal.org/project/issues/drupal
You can get the full Drupal repo here:
https://www.drupal.org/project/drupal/git-instructions
You can browse the full Drupal repo here:
https://git.drupalcode.org/project/drupal
HOW-TO: Test this Drupal component
In order to test this component, you'll need to get the entire Drupal repo and
run the tests there.
You'll find the tests under core/tests/Drupal/Tests/Component.
You can get the full Drupal repo here:
https://www.drupal.org/project/drupal/git-instructions
You can find more information about running PHPUnit tests with Drupal here:
https://www.drupal.org/node/2116263
Each component in the Drupal\Component namespace has its own annotated test
group. You can use this group to run only the tests for this component. Like
this:
$ ./vendor/bin/phpunit -c core --group FrontMatter
{
"name": "drupal/core-front-matter",
"description": "Component for parsing front matter from a source.",
"keywords": ["drupal"],
"homepage": "https://www.drupal.org/project/drupal",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=7.3.0",
"drupal/core-serialization": "^8.8"
},
"autoload": {
"psr-4": {
"Drupal\\Component\\FrontMatter\\": ""
}
}
}
......@@ -200,6 +200,15 @@
* }
* @endcode
*
* @section front_matter Front Matter
* Twig has been extended in Drupal to provide an easy way to parse front
* matter from template files. See \Drupal\Component\FrontMatter\FrontMatter
* for more information:
* @code
* $metadata = \Drupal::service('twig')->getTemplateMetadata('/path/to/template.html.twig');
* @endcode
* Note: all front matter is stripped from templates prior to rendering.
*
* @see hooks
* @see callbacks
* @see theme_render
......
......@@ -2,13 +2,18 @@
namespace Drupal\Core\Template;
use Drupal\Component\FrontMatter\Exception\FrontMatterParseException;
use Drupal\Component\FrontMatter\FrontMatter;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\PhpStorage\PhpStorageFactory;
use Drupal\Core\Render\Markup;
use Drupal\Core\Serialization\Yaml;
use Drupal\Core\State\StateInterface;
use Twig\Environment;
use Twig\Error\SyntaxError;
use Twig\Extension\SandboxExtension;
use Twig\Loader\LoaderInterface;
use Twig\Source;
/**
* A class that defines a Twig environment for Drupal.
......@@ -97,6 +102,36 @@ public function __construct($root, CacheBackendInterface $cache, $twig_extension
$this->addExtension($sandbox);
}
/**
* {@inheritdoc}
*/
public function compileSource(Source $source) {
// Note: always use \Drupal\Core\Serialization\Yaml here instead of the
// "serializer.yaml" service. This allows the core serializer to utilize
// core related functionality which isn't available as the standalone
// component based serializer.
$frontMatter = FrontMatter::create($source->getCode(), Yaml::class);
// Reconstruct the source if there is front matter data detected. Prepend
// the source with {% line \d+ %} to inform Twig that the source code
// actually starts on a different line past the front matter data. This is
// particularly useful when used in error reporting.
try {
if (($line = $frontMatter->getLine()) > 1) {
$content = "{% line $line %}" . $frontMatter->getContent();
$source = new Source($content, $source->getName(), $source->getPath());
}
}
catch (FrontMatterParseException $exception) {
// Convert parse exception into a syntax exception for Twig and append
// the path/name of the source to help further identify where it occurred.
$message = sprintf($exception->getMessage() . ' in %s', $source->getPath() ?: $source->getName());
throw new SyntaxError($message, $exception->getSourceLine(), $source, $exception);
}
return parent::compileSource($source);
}
/**
* Invalidates all compiled Twig templates.
*
......@@ -118,6 +153,37 @@ public function getTwigCachePrefix() {
return $this->twigCachePrefix;
}
/**
* Retrieves metadata associated with a template.
*
* @param string $name
* The name for which to calculate the template class name.
*
* @return array
* The template metadata, if any.
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\SyntaxError
*/
public function getTemplateMetadata(string $name): array {
$loader = $this->getLoader();
$source = $loader->getSourceContext($name);
// Note: always use \Drupal\Core\Serialization\Yaml here instead of the
// "serializer.yaml" service. This allows the core serializer to utilize
// core related functionality which isn't available as the standalone
// component based serializer.
try {
return FrontMatter::create($source->getCode(), Yaml::class)->getData();
}
catch (FrontMatterParseException $exception) {
// Convert parse exception into a syntax exception for Twig and append
// the path/name of the source to help further identify where it occurred.
$message = sprintf($exception->getMessage() . ' in %s', $source->getPath() ?: $source->getName());
throw new SyntaxError($message, $exception->getSourceLine(), $source, $exception);
}
}
/**
* Gets the template class associated with the given string.
*
......
<?php
namespace Drupal\KernelTests\Core\Theme;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\Component\FrontMatter\FrontMatterTest as ComponentFrontMatterTest;
use Symfony\Component\DependencyInjection\Definition;
use Twig\Error\Error;
use Twig\Error\SyntaxError;
/**
* Tests Twig front matter support.
*
* @covers \Drupal\Core\Template\Loader\FrontMatterLoaderDecorator
* @covers \Drupal\Core\Template\FrontMatterSourceDecorator
* @group Twig
*/
class FrontMatterTest extends KernelTestBase {
/**
* A broken source.
*/
const BROKEN_SOURCE = '<div>Hello {{ world</div>';
/**
* Twig service.
*
* @var \Drupal\Core\Template\TwigEnvironment
*/
protected $twig;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->twig = \Drupal::service('twig');
}
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->setDefinition('twig_loader__file_system', new Definition('Twig_Loader_Filesystem', [[sys_get_temp_dir()]]))
->addTag('twig.loader');
}
/**
* Creates a new temporary Twig file.
*
* @param string $content
* The contents of the Twig file to save.
*
* @return string
* The absolute path to the temporary file.
*/
protected function createTwigTemplate(string $content = ''): string {
$file = tempnam(sys_get_temp_dir(), 'twig') . ".html.twig";
file_put_contents($file, $content);
return $file;
}
/**
* Tests broken front matter.
*
* @covers \Drupal\Core\Template\TwigEnvironment::getTemplateMetadata
* @covers \Drupal\Component\FrontMatter\Exception\FrontMatterParseException
*/
public function testFrontMatterBroken() {
$source = "---\ncollection:\n- key: foo\n foo: bar\n---\n" . ComponentFrontMatterTest::SOURCE;
$file = $this->createTwigTemplate($source);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('An error occurred when attempting to parse front matter data on line 4 in ' . $file);
$this->twig->getTemplateMetadata(basename($file));
}
/**
* Test Twig template front matter.
*
* @param array|null $yaml
* The YAML used for metadata in a Twig template.
* @param int $line
* The expected line number where the source code starts.
* @param string $content
* The content to use for testing purposes.
*
* @covers \Drupal\Core\Template\TwigEnvironment::compileSource
* @covers \Drupal\Core\Template\TwigEnvironment::getTemplateMetadata
*
* @dataProvider \Drupal\Tests\Component\FrontMatter\FrontMatterTest::providerFrontMatterData
*/
public function testFrontMatter($yaml, $line, $content = ComponentFrontMatterTest::SOURCE) {
// Create a temporary Twig template.
$source = ComponentFrontMatterTest::createFrontMatterSource($yaml, $content);
$file = $this->createTwigTemplate($source);
$name = basename($file);
// Ensure the proper metadata is returned.
$metadata = $this->twig->getTemplateMetadata($name);
$this->assertEquals($yaml === NULL ? [] : $yaml, $metadata);
// Ensure the metadata is never rendered.
$output = $this->twig->load($name)->render();
$this->assertEquals($content, $output);
// Create a temporary Twig template.
$source = ComponentFrontMatterTest::createFrontMatterSource($yaml, static::BROKEN_SOURCE);
$file = $this->createTwigTemplate($source);
$name = basename($file);
try {
$this->twig->load($name);
}
catch (Error $error) {
$this->assertEquals($line, $error->getTemplateLine());
}
// Ensure string based templates work too.
try {
$this->twig->createTemplate($source)->render();
}
catch (Error $error) {
$this->assertEquals($line, $error->getTemplateLine());
}
}
}
<?php
namespace Drupal\Tests\Component\FrontMatter;
use Drupal\Component\FrontMatter\Exception\FrontMatterParseException;
use Drupal\Component\FrontMatter\FrontMatter;
use Drupal\Component\Serialization\Yaml;
use PHPUnit\Framework\TestCase;
/**
* Tests front matter parsing helper methods.
*
* @group FrontMatter
*
* @coversDefaultClass \Drupal\Component\FrontMatter\FrontMatter
*/
class FrontMatterTest extends TestCase {
/**
* A basic source string.
*/
const SOURCE = '<div>Hello world</div>';
/**
* Creates a front matter source string.
*
* @param array|null $yaml
* The YAML array to prepend as a front matter block.
* @param string $content
* The source contents.
*
* @return string
* The new source.
*/
public static function createFrontMatterSource(?array $yaml, string $content = self::SOURCE): string {
// Encode YAML and wrap in a front matter block.
$frontMatter = '';
if (is_array($yaml)) {
$yaml = $yaml ? trim(Yaml::encode($yaml)) . "\n" : '';
$frontMatter = FrontMatter::SEPARATOR . "\n$yaml" . FrontMatter::SEPARATOR . "\n";
}
return $frontMatter . $content;
}
/**
* Tests when a passed serializer doesn't implement the proper interface.
*
* @covers ::__construct
* @covers ::create
*/
public function testFrontMatterSerializerException() {
$this->expectException(\AssertionError::class);
$this->expectExceptionMessage('The $serializer parameter must reference a class that implements Drupal\Component\Serialization\SerializationInterface.');