WooCommerce Docs: Support Gutenberg block conversion with CommonMark, add some basic unit tests. (#39096)

* Extract docs manifest generation into a CLI tool
This commit is contained in:
Sam Seay 2023-07-12 15:29:15 +08:00 committed by GitHub
parent dddd0e65ac
commit e91a72b8a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 3278 additions and 174 deletions

View File

@ -50,6 +50,10 @@
<exclude-pattern>tests/</exclude-pattern>
</rule>
<rule ref="WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents">
<exclude-pattern>tests/src</exclude-pattern>
</rule>
<rule ref="WordPress.Files.FileName.InvalidClassFileName">
<exclude-pattern>includes/**/abstract-*.php</exclude-pattern>
<exclude-pattern>tests/</exclude-pattern>

View File

@ -0,0 +1,21 @@
{
"phpVersion": "7.4",
"plugins": [ "." ],
"config": {
"WP_DEBUG_LOG": true,
"WP_DEBUG_DISPLAY": true
},
"env": {
"development": {},
"tests": {
"port": 8086,
"plugins": [ "." ],
"themes": [
"https://downloads.wordpress.org/theme/twentynineteen.zip"
],
"config": {
"WP_TESTS_DOMAIN": "localhost"
}
}
}
}

View File

@ -12,9 +12,14 @@ Set up the monorepo as usual, now from this directory run `pnpm build` to build
This plugin creates a top level menu called "WooCommerce Docs" that you can navigate to once
you've mounted the plugin in your development environment.
There is a basic script that generates a manifest.json file from a set of example docs. You can run it via:
`pnpm generate-manifest`.
You can use monorepo utils from the repo root to generate new manifests:
```
pnpm utils md-docs create ./plugins/woocommerce-docs/example-docs woodocs --outputFilePath ./plugins/woocommerce-docs/scripts/manifest.json
```
To load the manifest as a source in the plugin go to the plugin page and add a manifest with url:
`http://your-local-wp-host/wp-content/plugins/woocommerce-docs/scripts/manifest.json`
Please note that if you're hosting the file within Docker, that localhost will not work as the host for your file because that's reserved for localhost within the container. You'll need to use the IP address of your machine instead or on Mac OS you can use the Docker DNS name `host.docker.internal`.

View File

@ -4,7 +4,7 @@
"type": "wordpress-plugin",
"require": {
"woocommerce/action-scheduler": "^3.6",
"erusev/parsedown": "^1.7"
"league/commonmark": "^2.4"
},
"autoload": {
"psr-4": {
@ -12,7 +12,9 @@
}
},
"require-dev": {
"woocommerce/woocommerce-sniffs": "^0.1.3"
"woocommerce/woocommerce-sniffs": "^0.1.3",
"phpunit/phpunit": "^9.6",
"yoast/phpunit-polyfills": "^2.0"
},
"config": {
"allow-plugins": {

File diff suppressed because it is too large Load Diff

View File

@ -7,3 +7,11 @@ title: What Went Wrong?
1. Restart?
2. Refresh?
3. Profit!
Try some different things. _Broken?_ Try something else. **Unresponsive?** Try again.
If you would like to do a search you can go to [A search engine](google.com).
---
![An image](https://picsum.photos/200/300 'This is an image.')

View File

@ -8,7 +8,8 @@
"build": "wp-scripts build",
"start": "wp-scripts start",
"postinstall": "composer install",
"generate-manifest": "ts-node ./scripts/generate-manifest.ts"
"test:env-setup": "wp-env start && wp-env run cli --env-cwd=wp-content/plugins/woocommerce-docs composer install",
"test:unit": "pnpm run test:env-setup && wp-env run tests-cli vendor/bin/phpunit --env-cwd=wp-content/plugins/woocommerce-docs"
},
"keywords": [],
"author": "",
@ -27,6 +28,7 @@
"@types/react-dom": "^17.0.2",
"@woocommerce/dependency-extraction-webpack-plugin": "workspace:*",
"@woocommerce/eslint-plugin": "workspace:*",
"@wordpress/env": "^8.2.0",
"@wordpress/prettier-config": "2.17.0",
"@wordpress/scripts": "^26.4.0",
"eslint": "^8.32.0",

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
verbose="true"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<testsuites>
<testsuite name="WooCommerceDocs Test Suite">
<directory suffix=".php">./tests/src</directory>
</testsuite>
</testsuites>
<coverage includeUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
<file>woocommerce-docs.php</file>
</include>
</coverage>
</phpunit>

View File

@ -1,115 +0,0 @@
/**
* External dependencies
*/
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { glob } from 'glob';
import crypto from 'crypto';
import process from 'process';
const branch = process.argv[ 2 ] || 'trunk'; // Use 'trunk' as the default branch if no argument is provided
interface Category {
[ key: string ]: unknown;
}
interface Post {
[ key: string ]: unknown;
}
function generatePageId( filePath: string, prefix = '' ) {
const hash = crypto.createHash( 'sha1' );
hash.update( prefix + filePath );
return hash.digest( 'hex' );
}
function generateHashOfString( str: string ) {
return crypto.createHash( 'sha256' ).update( str ).digest( 'hex' );
}
function generateRawGithubFileUrl(
owner: string,
repo: string,
gitBranch: string,
repoPath: string,
filePath: string
): string {
const relativePath = path.relative( repoPath, filePath );
const githubUrl = `https://raw.githubusercontent.com/${ owner }/${ repo }/${ gitBranch }/${ relativePath }`;
return githubUrl.replace( /\\/g, '/' ); // Ensure we use forward slashes in the URL.
}
async function processDirectory(
directory: string,
projectName: string,
checkReadme = true
): Promise< Category > {
let category: Category = {};
// Process README.md (if exists) for the category definition.
const readmePath = path.join( directory, 'README.md' );
if ( checkReadme && fs.existsSync( readmePath ) ) {
const readmeContent = fs.readFileSync( readmePath, 'utf-8' );
const readmeFrontmatter = matter( readmeContent ).data;
category = { ...readmeFrontmatter };
category.posts = [];
}
const markdownFiles = glob.sync( path.join( directory, '*.md' ) );
markdownFiles.forEach( ( filePath ) => {
if ( filePath !== readmePath || ! checkReadme ) {
// Skip README.md which we have already processed.
const fileContent = fs.readFileSync( filePath, 'utf-8' );
const fileFrontmatter = matter( fileContent ).data;
const post: Post = { ...fileFrontmatter };
// @ts-ignore
category.posts.push( {
...post,
url: generateRawGithubFileUrl(
'woocommerce',
'woocommerce',
branch,
path.join( __dirname, '../../../' ),
filePath
),
id: generatePageId( filePath, projectName ),
} );
}
} );
// Recursively process subdirectories.
category.categories = [];
const subdirectories = fs
.readdirSync( directory, { withFileTypes: true } )
.filter( ( dirent ) => dirent.isDirectory() )
.map( ( dirent ) => path.join( directory, dirent.name ) );
for ( const subdirectory of subdirectories ) {
const subcategory = await processDirectory( subdirectory, projectName );
// @ts-ignore
category.categories.push( subcategory );
}
return category;
}
async function processRootDirectory( directory: string, projectName: string ) {
return processDirectory( directory, projectName, false );
}
// Use the processRootDirectory function.
processRootDirectory( path.join( __dirname, '../example-docs' ), 'test-docs' )
.then( ( root ) => {
const rootHash = generateHashOfString( JSON.stringify( root ) );
// Add the root hash to the root object.
root.hash = rootHash;
// write it to a file in this directory
fs.writeFileSync(
path.join( __dirname, 'manifest.json' ),
JSON.stringify( root, null, 2 )
);
} )
.catch( ( err ) => {
console.error( err );
} );

View File

@ -5,8 +5,8 @@
"posts": [
{
"title": "Local Development",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/add/wc-docs-manifest/plugins/woocommerce-docs/example-docs/get-started/local-development.md",
"id": "052b40518676aa0fdf57bc0b71fdca8cd033f151"
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/plugins/woocommerce-docs/example-docs/get-started/local-development.md",
"id": "c068ce54044fa44c760a69bd71ef21274f2a5a37"
}
],
"categories": [
@ -15,8 +15,8 @@
"posts": [
{
"title": "What Went Wrong?",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/add/wc-docs-manifest/plugins/woocommerce-docs/example-docs/get-started/troubleshooting/what-went-wrong.md",
"id": "ce53918354bdeaa9d16db3e0964b56e1699c8220"
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/plugins/woocommerce-docs/example-docs/get-started/troubleshooting/what-went-wrong.md",
"id": "1f88c4d039e72c059c928ab475ad1ea0a02c8abb"
}
],
"categories": []
@ -28,12 +28,12 @@
"posts": [
{
"title": "Unit Testing",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/add/wc-docs-manifest/plugins/woocommerce-docs/example-docs/testing/unit-tests.md",
"id": "c9916c10ae1962ecaa4193ccdadd043241b8ec05"
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/plugins/woocommerce-docs/example-docs/testing/unit-tests.md",
"id": "120770c899215a889246b47ac883e4dda1f97b8b"
}
],
"categories": []
}
],
"hash": "7c6cdf9772ccf1f9327a35c0ef8e45413085225e20b1df503e2a19a444c106fb"
"hash": "180a6ce0bebb8e84072fda451758ef6326490a99e7b3a349b9e45baa8c7c54ac"
}

View File

@ -0,0 +1,161 @@
<?php
namespace WooCommerceDocs\Blocks;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
/**
* Class MarkdownParser
*/
class BlockConverter {
/** // phpcs:ignore Generic.Commenting.DocComment.MissingShort
*
* @var MarkdownParser The MarkdownParser instance.
*/
private $parser;
/**
* Constructor.
*/
public function __construct() {
$environment = new Environment();
$environment->addExtension( new CommonMarkCoreExtension() );
$this->parser = new MarkdownConverter( $environment );
}
/**
* Convert Markdown to Gutenberg blocks.
*
* @param string $content The Markdown content.
*
* @return string
*/
public function convert( $content ) {
$html = $this->parser->convert( $content )->__toString();
return $this->convert_html_to_blocks( $html );
}
/**
* Convert HTML to blocks.
*
* @param string $html The HTML content.
*/
private function convert_html_to_blocks( $html ) {
$blocks_html = '';
$dom = new \DOMDocument();
$dom->loadHTML( $html );
$xpath = new \DOMXPath( $dom );
$nodes = $xpath->query( '//body/*' );
foreach ( $nodes as $node ) {
$blocks_html .= $this->convert_node_to_block( $node );
}
return $blocks_html;
}
/**
* Convert a DOM node to a block.
*
* @param \DOMNode $node The DOM node.
*/
private function convert_node_to_block( $node ) {
$node_name = $node->nodeName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$node_value = $node->nodeValue; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$node_content = $this->convert_child_nodes_to_blocks( $node );
switch ( $node_name ) {
case 'p':
return $this->create_block( 'paragraph', $node_name, $node_content );
case 'h1':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 1 ) );
case 'h2':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 2 ) );
case 'h3':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 3 ) );
case 'h4':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 4 ) );
case 'h5':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 5 ) );
case 'h6':
return $this->create_block( 'heading', $node_name, $node_content, array( 'level' => 6 ) );
case 'ul':
return $this->create_block( 'list', $node_name, $node_content, array( 'ordered' => false ) );
case 'ol':
return $this->create_block( 'list', $node_name, $node_content, array( 'ordered' => true ) );
case 'li':
return $this->create_block( 'list-item', $node_name, $node_content );
case 'hr':
return $this->create_block( 'separator', $node_name, null );
default:
return $this->create_block( 'paragraph', $node_value );
}
}
/**
* Create a block.
*
* @param string $block_name The block name.
* @param string $node_name The node name.
* @param string $content The content.
* @param array $attrs The attributes.
*/
private function create_block( $block_name, $node_name, $content = null, $attrs = array() ) {
$json_attrs = count( $attrs ) > 0 ? ' ' . wp_json_encode( $attrs ) : '';
$block_html = "<!-- wp:{$block_name}{$json_attrs} -->\n";
// Special case for hr, at some point we could support other self-closing tags if needed.
if ( 'hr' === $node_name ) {
$block_html .= "<{$node_name} class=\"wp-block-separator has-alpha-channel-opacity\" />\n";
} elseif ( null !== $content ) {
$block_html .= "<{$node_name}>{$content}</{$node_name}>\n";
}
$block_html .= "<!-- /wp:{$block_name} -->\n";
return $block_html;
}
/**
* Convert child nodes to blocks.
*
* @param \DOMNode $node The DOM node.
*/
private function convert_child_nodes_to_blocks( $node ) {
$content = '';
foreach ( $node->childNodes as $child_node ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$node_type = $child_node->nodeType; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$node_name = $child_node->nodeName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( XML_ELEMENT_NODE === $node_type ) {
if ( 'a' === $node_name ) {
$href = esc_url( $child_node->getAttribute( 'href' ) );
$link_content = $this->convert_child_nodes_to_blocks( $child_node );
$content .= "<a href=\"{$href}\">{$link_content}</a>";
} elseif ( 'em' === $node_name || 'strong' === $node_name ) {
$inline_content = $this->convert_child_nodes_to_blocks( $child_node );
$content .= "<{$node_name}>{$inline_content}</{$node_name}>";
} elseif ( 'img' === $node_name ) {
// Only handle images as inline content for now due to how Markdown is processed by CommonMark.
$src = esc_url( $child_node->getAttribute( 'src' ) );
$alt = esc_attr( $child_node->getAttribute( 'alt' ) );
$content .= "<img src=\"{$src}\" alt=\"{$alt}\" />";
} else {
$content .= $this->convert_node_to_block( $child_node );
}
} elseif ( XML_TEXT_NODE === $node_type ) {
$content .= $child_node->nodeValue; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
}
return $content;
}
}

View File

@ -2,6 +2,8 @@
namespace WooCommerceDocs\Manifest;
use WooCommerceDocs\Blocks\BlockConverter;
/**
* Class ManifestProcessor
*
@ -19,14 +21,14 @@ class ManifestProcessor {
}
/**
* Get the parsedown parser
* Get the Markdown converter.
*/
private static function get_parser() {
static $parser = null;
if ( null === $parser ) {
$parser = new \Parsedown();
private static function get_converter() {
static $converter = null;
if ( null === $converter ) {
$converter = new BlockConverter();
}
return $parser;
return $converter;
}
/**
@ -78,14 +80,14 @@ class ManifestProcessor {
$content = preg_replace( '/^---[\s\S]*?---/', '', $content );
// Parse markdown.
$markdown_content = self::get_parser()->text( $content );
$blocks = self::get_converter()->convert( $content );
// If the post doesn't exist, create it.
if ( ! $existing_post ) {
$post_id = \WooCommerceDocs\Data\DocsStore::insert_docs_post(
array(
'post_title' => $post['title'],
'post_content' => $markdown_content,
'post_content' => $blocks,
'post_status' => 'publish',
),
$post['id']
@ -99,7 +101,7 @@ class ManifestProcessor {
array(
'ID' => $existing_post->ID,
'post_title' => $post['title'],
'post_content' => $markdown_content,
'post_content' => $blocks,
),
$post['id']
);

View File

@ -0,0 +1,13 @@
<?php // phpcs:ignore Squiz.Commenting.FileComment.Missing
/**
* PHPUnit bootstrap file
*
* @package WooCommerce_Docs
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php';
$tests_dir = getenv( 'WP_TESTS_DIR' );
require_once $tests_dir . '/includes/functions.php';
require_once $tests_dir . '/includes/bootstrap.php';

View File

@ -0,0 +1,18 @@
<?php
use WooCommerceDocs\Blocks\BlockConverter;
/**
* Class BlockConverterTest
*/
class BlockConverterTest extends WP_UnitTestCase {
/**
* Test blocks are converted correctly from sample markdown.
*/
public function test_blocks_converted() {
$block_converter = new BlockConverter();
$converted = $block_converter->convert( file_get_contents( __DIR__ . '/fixtures/test.md' ) );
$this->assertEquals( $converted, file_get_contents( __DIR__ . '/fixtures/expected.html' ) );
}
}

View File

@ -0,0 +1,68 @@
<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity" />
<!-- /wp:separator -->
<!-- wp:heading {"level":2} -->
<h2>title: Some frontmatter</h2>
<!-- /wp:heading -->
<!-- wp:heading {"level":1} -->
<h1>Heading 1</h1>
<!-- /wp:heading -->
<!-- wp:heading {"level":2} -->
<h2>Heading 2</h2>
<!-- /wp:heading -->
<!-- wp:heading {"level":3} -->
<h3>Heading 3</h3>
<!-- /wp:heading -->
<!-- wp:heading {"level":4} -->
<h4>Heading 4</h4>
<!-- /wp:heading -->
<!-- wp:heading {"level":5} -->
<h5>Heading 5</h5>
<!-- /wp:heading -->
<!-- wp:heading {"level":6} -->
<h6>Heading 6</h6>
<!-- /wp:heading -->
<!-- wp:list {"ordered":true} -->
<ol>
<!-- wp:list-item -->
<li>Item 1</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Item 2</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Item 3</li>
<!-- /wp:list-item -->
</ol>
<!-- /wp:list -->
<!-- wp:list {"ordered":false} -->
<ul>
<!-- wp:list-item -->
<li>Unordered Item 1</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Unordered Item 2</li>
<!-- /wp:list-item -->
<!-- wp:list-item -->
<li>Unordered Item 3</li>
<!-- /wp:list-item -->
</ul>
<!-- /wp:list -->
<!-- wp:paragraph -->
<p>Try some different things. <em>Italics</em> Try something else. <strong>Bold</strong> Try again.</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Here is a link: <a href="https://woocommerce.com">Woocommerce.com</a>.</p>
<!-- /wp:paragraph -->
<!-- wp:separator -->
<hr class="wp-block-separator has-alpha-channel-opacity" />
<!-- /wp:separator -->
<!-- wp:paragraph -->
<p><img src="https://picsum.photos/200/300" alt="An image" /></p>
<!-- /wp:paragraph -->

View File

@ -0,0 +1,31 @@
---
title: Some frontmatter
---
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
1. Item 1
2. Item 2
3. Item 3
- Unordered Item 1
- Unordered Item 2
- Unordered Item 3
Try some different things. _Italics_ Try something else. **Bold** Try again.
Here is a link: [Woocommerce.com](https://woocommerce.com).
---
![An image](https://picsum.photos/200/300 'This is an image.')

View File

@ -0,0 +1,57 @@
<?php
use WooCommerceDocs\Data\ManifestStore;
/**
* Class ManifestStoreTest
*/
class ManifestStoreTest extends WP_UnitTestCase {
/**
* Test the manifest store stores manifests in a list.
*/
public function test_adding_a_manifest_stores_it_in_the_list() {
ManifestStore::add_manifest( 'https://example.com/manifest.json' );
ManifestStore::update_manifest( 'https://example.com/manifest.json', array( 'foo' => 'bar' ) );
$manifest_list = ManifestStore::get_manifest_list();
$this->assertEquals( $manifest_list, array( array( 'https://example.com/manifest.json', array( 'foo' => 'bar' ) ) ) );
}
/**
* Test retrieving a single manifest by url.
*/
public function test_retrieving_a_single_manifest_by_url() {
ManifestStore::add_manifest( 'https://example.com/manifest.json' );
ManifestStore::update_manifest( 'https://example.com/manifest.json', array( 'foo' => 'bar' ) );
$manifest = ManifestStore::get_manifest_by_url( 'https://example.com/manifest.json' );
$this->assertEquals( $manifest['foo'], 'bar' );
}
/**
* Test removing a manifest by url.
*/
public function test_removing_a_manifest_by_url() {
ManifestStore::add_manifest( 'https://example.com/manifest.json' );
ManifestStore::remove_manifest( 'https://example.com/manifest.json' );
$manifest_list = ManifestStore::get_manifest_list();
$this->assertEquals( $manifest_list, array() );
}
/**
* Test updating an existing manifest.
*/
public function test_updating_a_manifest() {
ManifestStore::add_manifest( 'https://example.com/manifest.json' );
ManifestStore::update_manifest( 'https://example.com/manifest.json', array( 'foo' => 'bar' ) );
$manifest = ManifestStore::get_manifest_by_url( 'https://example.com/manifest.json' );
$this->assertEquals( $manifest['foo'], 'bar' );
ManifestStore::update_manifest( 'https://example.com/manifest.json', array( 'foo' => 'baz' ) );
$updated_manifest = ManifestStore::get_manifest_by_url( 'https://example.com/manifest.json' );
$this->assertEquals( $updated_manifest['foo'], 'baz' );
}
}

View File

@ -3214,6 +3214,9 @@ importers:
'@woocommerce/eslint-plugin':
specifier: workspace:*
version: link:../../packages/js/eslint-plugin
'@wordpress/env':
specifier: ^8.2.0
version: 8.2.0
'@wordpress/prettier-config':
specifier: 2.17.0
version: 2.17.0(wp-prettier@2.8.5)
@ -3468,6 +3471,9 @@ importers:
graphql:
specifier: ^16.6.0
version: 16.6.0
gray-matter:
specifier: ^4.0.3
version: 4.0.3
octokit:
specifier: ^2.0.14
version: 2.0.14
@ -4034,7 +4040,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@jridgewell/trace-mapping': 0.3.16
'@jridgewell/trace-mapping': 0.3.17
commander: 4.1.1
convert-source-map: 1.8.0
fs-readdir-recursive: 1.1.0
@ -5197,7 +5203,7 @@ packages:
dependencies:
'@babel/core': 7.12.9
'@babel/helper-create-class-features-plugin': 7.19.0(@babel/core@7.12.9)
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
transitivePeerDependencies:
- supports-color
@ -5209,7 +5215,7 @@ packages:
dependencies:
'@babel/core': 7.17.8
'@babel/helper-create-class-features-plugin': 7.19.0(@babel/core@7.17.8)
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
transitivePeerDependencies:
- supports-color
dev: true
@ -6342,7 +6348,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
dev: true
/@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.21.3):
@ -6351,7 +6357,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.12.9):
resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
@ -6393,7 +6399,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
dev: true
/@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.21.3):
@ -6402,7 +6408,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.12.9):
resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
@ -6897,7 +6903,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.17.8
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
dev: true
/@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.21.3):
@ -6907,7 +6913,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-plugin-utils': 7.20.2
/@babel/plugin-syntax-typescript@7.16.7(@babel/core@7.17.8):
resolution: {integrity: sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==}
@ -10388,8 +10394,8 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.3
'@babel/helper-plugin-utils': 7.21.5
'@babel/helper-validator-option': 7.21.0
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-validator-option': 7.18.6
'@babel/plugin-transform-flow-strip-types': 7.16.7(@babel/core@7.21.3)
dev: true
@ -12240,6 +12246,7 @@ packages:
dependencies:
'@jridgewell/resolve-uri': 3.1.0
'@jridgewell/sourcemap-codec': 1.4.14
dev: true
/@jridgewell/trace-mapping@0.3.17:
resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==}
@ -22182,8 +22189,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
dependencies:
browserslist: 4.21.4
caniuse-lite: 1.0.30001418
browserslist: 4.20.2
caniuse-lite: 1.0.30001352
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@ -23739,7 +23746,6 @@ packages:
escalade: 3.1.1
node-releases: 2.0.6
picocolors: 1.0.0
dev: true
/browserslist@4.20.4:
resolution: {integrity: sha512-ok1d+1WpnU24XYN7oC3QWgTyMhY/avPJ/r9T00xxvUOIparA/gc+UPUMaod3i+G6s+nI2nUb9xZ5k794uIwShw==}
@ -29694,7 +29700,6 @@ packages:
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
dev: true
/gridicons@3.4.0(react@17.0.2):
resolution: {integrity: sha512-GikyCOcfhwHSN8tfsZvcWwWSaRLebVZCvDzfFg0X50E+dIAnG2phfFUTNa06dXA09kqRYCdnu8sPO8pSYO3UVA==}
@ -41674,7 +41679,6 @@ packages:
dependencies:
extend-shallow: 2.0.1
kind-of: 6.0.3
dev: true
/seed-random@2.2.0:
resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==}
@ -42649,7 +42653,6 @@ packages:
/strip-bom-string@1.0.0:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
dev: true
/strip-bom@2.0.0:
resolution: {integrity: sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==}

View File

@ -22,6 +22,7 @@
"figlet": "^1.6.0",
"glob": "^10.2.4",
"graphql": "^16.6.0",
"gray-matter": "^4.0.3",
"octokit": "^2.0.14",
"ora": "^5.4.1",
"promptly": "^3.2.0",

View File

@ -1,3 +1,9 @@
jest.mock( 'uuid', () => {
return {
v4: jest.fn( () => 1 ),
};
} );
/**
* External dependencies
*/

View File

@ -11,6 +11,7 @@ import dotenv from 'dotenv';
*/
import CodeFreeze from './code-freeze/commands';
import Slack from './slack/commands/slack';
import Manifest from './md-docs/commands';
import Changefile from './changefile';
import WorkflowProfiler from './workflow-profiler/commands';
import { Logger } from './core/logger';
@ -32,7 +33,8 @@ const program = new Command()
.addCommand( CodeFreeze )
.addCommand( Slack )
.addCommand( Changefile )
.addCommand( WorkflowProfiler );
.addCommand( WorkflowProfiler )
.addCommand( Manifest );
program.exitOverride();

View File

@ -0,0 +1,23 @@
# Markdown Docs CLI tool
This is a CLI tool designed to generate JSON manifests of Markdown files in a directory.
This manifest is currently designed to be consumed by the [WooCommerce Docs](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce-docs) plugin.
## Usage
This command is built on postinstall and can be run from monorepo root.
To create a manifest:
```
pnpm utils md-docs create <path-to-directory> <projectName>
```
### Arguments and options
To find out more about the arguments and options available, run:
```
pnpm utils md-docs create --help
```

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
/**
* Internal dependencies
*/
import { generateManifestCommand } from './manifest/create';
/**
* Internal dependencies
*/
const program = new Command( 'md-docs' )
.description( 'Utilities for generating markdown doc manifests.' )
.addCommand( generateManifestCommand, { isDefault: true } );
export default program;

View File

@ -0,0 +1,82 @@
/**
* External dependencies
*/
import { writeFile } from 'fs';
import { Command } from '@commander-js/extra-typings';
import path from 'path';
/**
* Internal dependencies
*/
import { generateManifestFromDirectory } from '../../../lib/generate-manifest';
import { Logger } from '../../../../core/logger';
export const generateManifestCommand = new Command( 'create' )
.description(
'Create a manifest file representing the contents of a markdown directory.'
)
.argument(
'<directory>',
'Path to directory of Markdown files to generate the manifest from.'
)
.argument(
'<projectName>',
'Name of the project to generate the manifest for, used to uniquely identify manifest entries.'
)
.option(
'-o --outputFilePath <outputFilePath>',
'Full path and filename of where to output the manifest.',
`${ process.cwd() }/manifest.json`
)
.option(
'-b --baseUrl <baseUrl>',
'Base url to resolve markdown file URLs to in the manifest.',
'https://raw.githubusercontent.com/woocommerce/woocommerce/trunk'
)
.option(
'-r --rootDir <rootDir>',
'Root directory of the markdown files, used to generate URLs.',
process.cwd()
)
.action( async ( dir, projectName, options ) => {
const { outputFilePath, baseUrl, rootDir } = options;
// determine if the rootDir is absolute or relative
const absoluteRootDir = path.isAbsolute( rootDir )
? rootDir
: path.join( process.cwd(), rootDir );
const absoluteSubDir = path.isAbsolute( dir )
? dir
: path.join( process.cwd(), dir );
const absoluteOutputFilePath = path.isAbsolute( outputFilePath )
? outputFilePath
: path.join( process.cwd(), outputFilePath );
Logger.startTask( 'Generating manifest' );
const manifest = await generateManifestFromDirectory(
absoluteRootDir,
absoluteSubDir,
projectName,
baseUrl
);
Logger.endTask();
Logger.startTask( 'Writing manifest' );
await writeFile(
absoluteOutputFilePath,
JSON.stringify( manifest, null, 2 ),
( err ) => {
if ( err ) {
Logger.error( err );
}
}
);
Logger.endTask();
Logger.notice( `Manifest output at ${ outputFilePath }` );
} );

View File

@ -0,0 +1,3 @@
---
title: Getting Started with WooCommerce
---

View File

@ -0,0 +1,9 @@
---
title: Local Development
---
## Local Development
1. Install
2. Configure
3. Profit!

View File

@ -0,0 +1,3 @@
---
title: Troubleshooting Problems
---

View File

@ -0,0 +1,17 @@
---
title: What Went Wrong?
---
## Try some troubleshooting
1. Restart?
2. Refresh?
3. Profit!
Try some different things. _Broken?_ Try something else. **Unresponsive?** Try again.
If you would like to do a search you can go to [A search engine](google.com).
---
![An image](https://picsum.photos/200/300 'This is an image.')

View File

@ -0,0 +1,3 @@
---
title: Testing WooCommerce
---

View File

@ -0,0 +1,7 @@
---
title: Unit Testing
---
## Unit Test
It's simple really, write tests!

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import path from 'path';
/**
* Internal dependencies
*/
import { generateFileUrl } from '../generate-manifest';
describe( 'generateFileUrl', () => {
it( 'should generate a file url relative to the root directory provided', () => {
const url = generateFileUrl(
'https://example.com',
path.join( __dirname, 'fixtures/example-docs' ),
path.join( __dirname, 'fixtures/example-docs/get-started' ),
path.join(
__dirname,
'fixtures/example-docs/get-started/local-development.md'
)
);
expect( url ).toBe(
'https://example.com/get-started/local-development.md'
);
} );
it( 'should throw an error if relative paths are passed', () => {
expect( () =>
generateFileUrl(
'https://example.com',
'./example-docs',
path.join( __dirname, 'fixtures/example-docs/get-started' ),
path.join(
__dirname,
'fixtures/example-docs/get-started/local-development.md'
)
)
).toThrow();
expect( () =>
generateFileUrl(
'https://example.com',
path.join( __dirname, 'fixtures/example-docs' ),
'./get-started',
path.join(
__dirname,
'fixtures/example-docs/get-started/local-development.md'
)
)
).toThrow();
expect( () =>
generateFileUrl(
'https://example.com',
path.join( __dirname, 'fixtures/example-docs' ),
path.join( __dirname, 'fixtures/example-docs/get-started' ),
'./local-development.md'
)
).toThrow();
} );
} );

View File

@ -0,0 +1,69 @@
/**
* External dependencies
*/
import path from 'path';
/**
* Internal dependencies
*/
import { generateManifestFromDirectory } from '../generate-manifest';
describe( 'generateManifest', () => {
const dir = path.join( __dirname, './fixtures/example-docs' );
const rootDir = path.join( __dirname, './fixtures' );
it( 'should generate a manifest with the correct category structure', async () => {
// generate the manifest from fixture directory
const manifest = await generateManifestFromDirectory(
rootDir,
dir,
'example-docs',
'https://example.com'
);
const topLevelCategories = manifest.categories;
expect( topLevelCategories[ 0 ].title ).toEqual(
'Getting Started with WooCommerce'
);
expect( topLevelCategories[ 1 ].title ).toEqual(
'Testing WooCommerce'
);
const subCategories = topLevelCategories[ 0 ].categories;
expect( subCategories[ 0 ].title ).toEqual(
'Troubleshooting Problems'
);
} );
it( 'should create post urls with the correct url', async () => {
const manifest = await generateManifestFromDirectory(
rootDir,
dir,
'example-docs',
'https://example.com'
);
expect( manifest.categories[ 0 ].posts[ 0 ].url ).toEqual(
'https://example.com/example-docs/get-started/local-development.md'
);
expect(
manifest.categories[ 0 ].categories[ 0 ].posts[ 0 ].url
).toEqual(
'https://example.com/example-docs/get-started/troubleshooting/what-went-wrong.md'
);
} );
it( 'should create a hash for each manifest', async () => {
const manifest = await generateManifestFromDirectory(
rootDir,
dir,
'example-docs',
'https://example.com'
);
expect( manifest.hash ).not.toBeUndefined();
} );
} );

View File

@ -0,0 +1,144 @@
/**
* External dependencies
*/
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { glob } from 'glob';
import crypto from 'crypto';
interface Category {
[ key: string ]: unknown;
posts?: Post[];
categories?: Category[];
}
interface Post {
[ key: string ]: unknown;
}
function generatePageId( filePath: string, prefix = '' ) {
const hash = crypto.createHash( 'sha1' );
hash.update( prefix + filePath );
return hash.digest( 'hex' );
}
/**
* Generates a file url relative to the root directory provided.
*
* @param baseUrl The base url to use for the file url.
* @param rootDirectory The root directory where the file resides.
* @param subDirectory The sub-directory where the file resides.
* @param absoluteFilePath The absolute path to the file.
* @return The file url.
*/
export const generateFileUrl = (
baseUrl: string,
rootDirectory: string,
subDirectory: string,
absoluteFilePath: string
) => {
// check paths are absolute
for ( const filePath of [
rootDirectory,
subDirectory,
absoluteFilePath,
] ) {
if ( ! path.isAbsolute( filePath ) ) {
throw new Error(
`File URLs cannot be generated without absolute paths. ${ filePath } is not absolute.`
);
}
}
// Generate a path from the subdirectory to the file path.
const relativeFilePath = path.resolve( subDirectory, absoluteFilePath );
// Determine the relative path from the rootDirectory to the filePath.
const relativePath = path.relative( rootDirectory, relativeFilePath );
return `${ baseUrl }/${ relativePath }`;
};
async function processDirectory(
rootDirectory: string,
subDirectory: string,
projectName: string,
baseUrl: string,
checkReadme = true
): Promise< Category > {
let category: Category = {};
// Process README.md (if exists) for the category definition.
const readmePath = path.join( subDirectory, 'README.md' );
if ( checkReadme && fs.existsSync( readmePath ) ) {
const readmeContent = fs.readFileSync( readmePath, 'utf-8' );
const readmeFrontmatter = matter( readmeContent ).data;
category = { ...readmeFrontmatter };
category.posts = [];
}
const markdownFiles = glob.sync( path.join( subDirectory, '*.md' ) );
markdownFiles.forEach( ( filePath ) => {
if ( filePath !== readmePath || ! checkReadme ) {
// Skip README.md which we have already processed.
const fileContent = fs.readFileSync( filePath, 'utf-8' );
const fileFrontmatter = matter( fileContent ).data;
const post: Post = { ...fileFrontmatter };
category.posts.push( {
...post,
url: generateFileUrl(
baseUrl,
rootDirectory,
subDirectory,
filePath
),
id: generatePageId( filePath, projectName ),
} );
}
} );
// Recursively process subdirectories.
category.categories = [];
const subdirectories = fs
.readdirSync( subDirectory, { withFileTypes: true } )
.filter( ( dirent ) => dirent.isDirectory() )
.map( ( dirent ) => path.join( subDirectory, dirent.name ) );
for ( const subdirectory of subdirectories ) {
const subcategory = await processDirectory(
rootDirectory,
subdirectory,
projectName,
baseUrl
);
category.categories.push( subcategory );
}
return category;
}
export async function generateManifestFromDirectory(
rootDirectory: string,
subDirectory: string,
projectName: string,
baseUrl: string
) {
const manifest = await processDirectory(
rootDirectory,
subDirectory,
projectName,
baseUrl,
false
);
// Generate hash of the manifest contents.
const hash = crypto
.createHash( 'sha256' )
.update( JSON.stringify( manifest ) )
.digest( 'hex' );
return { ...manifest, hash };
}