Skip to content
Draft
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
136 changes: 110 additions & 26 deletions .github/scripts/lint-ecosystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ const basePathEcosystemDocFile = path.join('docs', 'Guides', 'Ecosystem.md')
const ecosystemDocFile = path.join(__dirname, '..', '..', basePathEcosystemDocFile)
const failureTypes = {
improperFormat: 'improperFormat',
outOfOrderItem: 'outOfOrderItem'
outOfOrderItem: 'outOfOrderItem',
invalidTags: 'invalidTags'
}
const listItemRegex = /^- \[`(.+)`\]/
const tablePluginCellRegex = /^\[`([^`]+)`\]\(.+\)$/
const tagListRegex = /^[a-z0-9-]+(?:, [a-z0-9-]+)*$/

module.exports = async function ({ core }) {
async function lintEcosystem ({ core }) {
const results = await runCheck()
await handleResults({ core }, results)
}
Expand All @@ -23,15 +27,26 @@ async function runCheck () {
crlfDelay: Infinity
})

const lines = []
for await (const line of rl) {
lines.push(line)
}

return lintEcosystemLines(lines)
}

function lintEcosystemLines (lines) {
const failures = []
const successes = []
const moduleNameRegex = /^- \[`(.+)`\]/
let lineNumber = 0
let modules = []
let grouping = 'core'

for await (const line of rl) {
lineNumber += 1
for (let index = 0; index < lines.length; index++) {
const line = lines[index].replace(/\r$/, '')
const lineNumber = index + 1

// Community Tools starts with the Community prefix, so the second
// assignment is what selects that section.
if (line.startsWith('#### [Community]')) {
grouping = 'community'
modules = []
Expand All @@ -42,13 +57,12 @@ async function runCheck () {
modules = []
}

if (line.startsWith('- [') !== true) {
const entry = parseEntry(line)
if (entry === null) {
continue
}

const moduleNameTest = moduleNameRegex.exec(line)

if (moduleNameTest === null) {
if (entry.moduleName === null) {
failures.push({
lineNumber,
grouping,
Expand All @@ -58,28 +72,88 @@ async function runCheck () {
continue
}

const moduleName = moduleNameTest[1]
if (modules.length > 0) {
if (compare(moduleName, modules.at(-1)) > 0) {
failures.push({
lineNumber,
moduleName,
grouping,
type: failureTypes.outOfOrderItem
})
} else {
successes.push({ moduleName, lineNumber, grouping })
}
} else {
// We have to push the first item found or we are missing items from the list
successes.push({ moduleName, lineNumber, grouping })
if (modules.length > 0 && compare(entry.moduleName, modules.at(-1)) > 0) {
failures.push({
lineNumber,
moduleName: entry.moduleName,
grouping,
type: failureTypes.outOfOrderItem
})
} else if (entry.tagError !== true) {
successes.push({ moduleName: entry.moduleName, lineNumber, grouping })
}
modules.push(moduleName)

if (entry.tagError === true) {
failures.push({
lineNumber,
moduleName: entry.moduleName,
grouping,
type: failureTypes.invalidTags
})
}

modules.push(entry.moduleName)
}

return { failures, successes }
}

function parseEntry (line) {
if (line.startsWith('- [')) {
const moduleNameTest = listItemRegex.exec(line)
if (moduleNameTest === null) {
return { moduleName: null }
}

return { moduleName: moduleNameTest[1], tagError: false }
}

if (line.startsWith('| [')) {
return parseTableEntry(line)
}

return null
}

function parseTableEntry (line) {
const cells = splitTableRow(line)
if (cells.length !== 3) {
return { moduleName: null }
}

const pluginMatch = tablePluginCellRegex.exec(cells[0])
if (pluginMatch === null) {
return { moduleName: null }
}

return {
moduleName: pluginMatch[1],
tagError: isValidTagList(cells[2]) !== true
}
}

function splitTableRow (line) {
const trimmed = line.trim()
if (trimmed.startsWith('|') !== true || trimmed.endsWith('|') !== true) {
return []
}

return trimmed.slice(1, -1).split('|').map((cell) => cell.trim())
}

function isValidTagList (tags) {
if (tagListRegex.test(tags) !== true) {
return false
}

const parts = tags.split(', ')
const sorted = [...parts].sort((left, right) => {
return left.localeCompare(right, 'en', { sensitivity: 'base' })
})

return parts.every((part, index) => part === sorted[index])
}

async function handleResults (scriptLibs, results) {
const { core } = scriptLibs
const { failures, successes } = results
Expand Down Expand Up @@ -122,6 +196,12 @@ async function handleResults (scriptLibs, results) {
file: basePathEcosystemDocFile,
startLine: failure.lineNumber
})
} else if (failure.type === failureTypes.invalidTags) {
core.error('Tags must be lowercase, comma-separated, and in alphabetical order', {
title: 'Invalid tags',
file: basePathEcosystemDocFile,
startLine: failure.lineNumber
})
} else {
core.error('Unknown error')
}
Expand All @@ -138,3 +218,7 @@ function compare (current, previous) {
{ sensitivity: 'base' }
)
}

module.exports = lintEcosystem
module.exports.lintEcosystemLines = lintEcosystemLines
module.exports.failureTypes = failureTypes
1 change: 1 addition & 0 deletions .github/workflows/lint-ecosystem-order.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- main
paths:
- "**/Ecosystem.md"
- ".github/scripts/lint-ecosystem.js"

permissions:
contents: read
Expand Down
28 changes: 13 additions & 15 deletions docs/Guides/Ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,19 +731,17 @@ middlewares into Fastify plugins

#### [Community Tools](#community-tools)

- [`fast-maker`](https://github.com/imjuni/fast-maker) route configuration
generator by directory structure.
- [`fastify-flux`](https://github.com/Jnig/fastify-flux) Tool for building
Fastify APIs using decorators and convert Typescript interface to JSON Schema.
- [`fastify-intlayer`](https://intlayer.org/doc/environment/fastify)
i18n solution for error handling, email template
- [`jeasx`](https://www.jeasx.dev)
A flexible server-rendering framework built on Fastify
that leverages asynchronous JSX to simplify web development.
- [`simple-tjscli`](https://github.com/imjuni/simple-tjscli) CLI tool to
generate JSON Schema from TypeScript interfaces.
- [`vite-plugin-fastify`](https://github.com/Vanilla-IceCream/vite-plugin-fastify)
Fastify plugin for Vite with Hot-module Replacement.
- [`vite-plugin-fastify-routes`](https://github.com/Vanilla-IceCream/vite-plugin-fastify-routes)
File-based routing for Fastify applications using Vite.
Plugins in this section stay in alphabetical order. The `tags` column
lists one or more lowercase labels, separated by a comma and a space,
so a plugin can be found without moving it into another section.

| Plugin | Description | Tags |
| ------ | ----------- | ---- |
| [`fast-maker`](https://github.com/imjuni/fast-maker) | route configuration generator by directory structure. | codegen, tooling |
| [`fastify-flux`](https://github.com/Jnig/fastify-flux) | Tool for building Fastify APIs using decorators and convert Typescript interface to JSON Schema. | codegen, schema, tooling |
| [`fastify-intlayer`](https://intlayer.org/doc/environment/fastify) | i18n solution for error handling, email template | i18n |
| [`jeasx`](https://www.jeasx.dev) | A flexible server-rendering framework built on Fastify that leverages asynchronous JSX to simplify web development. | framework, ssr |
| [`simple-tjscli`](https://github.com/imjuni/simple-tjscli) | CLI tool to generate JSON Schema from TypeScript interfaces. | cli, codegen, schema |
| [`vite-plugin-fastify`](https://github.com/Vanilla-IceCream/vite-plugin-fastify) | Fastify plugin for Vite with Hot-module Replacement. | tooling, vite |
| [`vite-plugin-fastify-routes`](https://github.com/Vanilla-IceCream/vite-plugin-fastify-routes) | File-based routing for Fastify applications using Vite. | routing, tooling, vite |

115 changes: 115 additions & 0 deletions test/scripts/lint-ecosystem.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use strict'

const { describe, it } = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')

const {
lintEcosystemLines,
failureTypes
} = require('../../.github/scripts/lint-ecosystem')

function failureTypesOf (result) {
return result.failures.map((failure) => failure.type)
}

describe('lintEcosystemLines', () => {
it('keeps alphabetical list items and starts each section over', () => {
const result = lintEcosystemLines([
'- [`alpha`](https://example.com/alpha) first',
'- [`beta`](https://example.com/beta) second',
'#### [Community](#community)',
'- [`zeta`](https://example.com/zeta) later section'
])

assert.deepStrictEqual(failureTypesOf(result), [])
assert.deepStrictEqual(
result.successes.map((success) => [success.grouping, success.moduleName]),
[
['core', 'alpha'],
['core', 'beta'],
['community', 'zeta']
]
)
})

it('flags a list item that is out of order or missing backticks', () => {
const result = lintEcosystemLines([
'- [`beta`](https://example.com/beta) second',
'- [`alpha`](https://example.com/alpha) first',
'- [gamma](https://example.com/gamma) plain'
])

assert.deepStrictEqual(failureTypesOf(result), [
failureTypes.outOfOrderItem,
failureTypes.improperFormat
])
assert.strictEqual(result.failures[0].moduleName, 'alpha')
assert.strictEqual(result.failures[1].moduleName, 'unknown')
})

it('reads a tags table without treating the header as a plugin', () => {
const result = lintEcosystemLines([
'#### [Community Tools](#community-tools)',
'Plugins in this section stay in alphabetical order.',
'| Plugin | Description | Tags |',
'| ------ | ----------- | ---- |',
'| [`fast-maker`](https://example.com/fast-maker) | generates routes. | codegen, tooling |',
'| [`fastify-flux`](https://example.com/fastify-flux) | builds APIs. | codegen, schema, tooling |'
])

assert.deepStrictEqual(failureTypesOf(result), [])
assert.deepStrictEqual(
result.successes.map((success) => success.moduleName),
['fast-maker', 'fastify-flux']
)
assert.ok(result.successes.every((success) => success.grouping === 'community-tools'))
})

it('flags an out-of-order table row and tags that are not normalized', () => {
const result = lintEcosystemLines([
'#### [Community Tools](#community-tools)',
'| [`beta`](https://example.com/beta) | second. | Tooling |',
'| [`alpha`](https://example.com/alpha) | first. | tooling, codegen |',
'| [`gamma`](https://example.com/gamma) | third. | codegen, tooling |'
])

assert.deepStrictEqual(failureTypesOf(result), [
failureTypes.invalidTags,
failureTypes.outOfOrderItem,
failureTypes.invalidTags
])
assert.strictEqual(result.failures[0].moduleName, 'beta')
assert.strictEqual(result.failures[1].moduleName, 'alpha')
assert.strictEqual(result.failures[2].moduleName, 'alpha')
})

it('flags a table row whose plugin cell has no backticks', () => {
const result = lintEcosystemLines([
'| [fast-maker](https://example.com/fast-maker) | generates routes. | tooling |'
])

assert.deepStrictEqual(failureTypesOf(result), [failureTypes.improperFormat])
})

it('accepts the checked-in Ecosystem guide', () => {
const content = fs.readFileSync(
path.join(__dirname, '../../docs/Guides/Ecosystem.md'),
'utf8'
)
const result = lintEcosystemLines(content.split('\n'))
const tools = result.successes.filter((success) => success.grouping === 'community-tools')

assert.deepStrictEqual(failureTypesOf(result), [])
assert.deepStrictEqual(tools.map((success) => success.moduleName), [
'fast-maker',
'fastify-flux',
'fastify-intlayer',
'jeasx',
'simple-tjscli',
'vite-plugin-fastify',
'vite-plugin-fastify-routes'
])
})
})
Loading