Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# https://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

[*.yml]
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@
.gitattributes export-ignore
.claude/ export-ignore
.github/ export-ignore
.editorconfig export-ignore
.stylelintrc.json export-ignore
package.json export-ignore
package-lock.json export-ignore
lint/ export-ignore
19 changes: 19 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Lint

on:
push:
branches: [develop-33]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
# vnu-jar is optional: HTML validation needs a running board, so CI skips its Java download.
- run: npm ci --omit=optional
- run: npm run lint
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ Icon
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.com.apple.timemachine.donotpresent

### Node
node_modules/
Expand Down
33 changes: 33 additions & 0 deletions .stylelintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"plugins": ["@stylistic/stylelint-plugin"],
"ignoreFiles": ["theme/print.css"],
"rules": {
"annotation-no-unknown": true,
"at-rule-no-unknown": true,
"block-no-empty": true,
"color-no-invalid-hex": true,
"comment-no-empty": true,
"declaration-block-no-duplicate-custom-properties": true,
"font-family-no-duplicate-names": true,
"function-calc-no-unspaced-operator": true,
"keyframe-declaration-no-important": true,
"media-feature-name-no-unknown": true,
"named-grid-areas-no-invalid": true,
"no-duplicate-at-import-rules": true,
"no-invalid-double-slash-comments": true,
"no-invalid-position-at-import-rule": true,
"property-no-unknown": true,
"selector-pseudo-class-no-unknown": true,
"selector-pseudo-element-no-unknown": true,
"selector-type-no-unknown": [true, { "ignore": ["custom-elements"] }],
"string-no-newline": true,
"unit-no-unknown": true,

"declaration-block-no-duplicate-properties": [true, { "severity": "warning", "ignore": ["consecutive-duplicates-with-different-syntaxes"] }],
"declaration-block-no-shorthand-property-overrides": [true, { "severity": "warning" }],
"declaration-no-important": [true, { "severity": "warning" }],
"no-duplicate-selectors": [true, { "severity": "warning" }],
"@stylistic/indentation": ["tab", { "severity": "warning" }],
"@stylistic/no-eol-whitespace": [true, { "severity": "warning" }]
}
}
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ The `contrib` folder contains a Photoshop PSD with an alternative icon set.
## Changes
See [contrib/CHANGELOG.md](contrib/CHANGELOG.md) for the full version history.

## Development
The repository includes lint tooling, which is left out of the style package. With Node.js 24:

- `npm ci`, then `npm run lint` checks the theme CSS with stylelint and the templates with a phpBB-specific checker (legacy syntax, `DEFINE`, extension-owned variables). GitHub Actions runs the same on every pull request.
- `npm run validate` checks rendered pages of a running board with the W3C Nu HTML Checker (needs Java). Set `BOARD_URL` to the board root, and `STYLE_ID` to force the style while *Override user style* is off.

Warnings mark cleanups that are still pending. They don't fail the build, only errors do.

## License
[GNU General Public License v2](https://opensource.org/licenses/GPL-2.0)

Expand Down
43 changes: 43 additions & 0 deletions lint/lint-templates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// phpBB-specific template checks that generic Twig linters cannot do: they choke on phpBB tags
// such as EVENT and INCLUDECSS.
// Usage: node lint/lint-templates.mjs [template dir]
// Errors exit 1. Warnings are printed but do not fail, so a cleanup rule can be tracked before it is
// enforced; switch its severity to 'error' when the cleanup lands.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const dir = process.argv[2] ?? 'template';

// Template variables owned by extensions. A style must not reference them; the extension injects
// its markup through a template event instead.
const EXTENSION_VARS = ['HEADERLINKS_CODE', 'TOPBAR_CODE', 'ADS_INDEX_CODE', 'RECENT_TOPICS_DISPLAY'];

const RULES = [
{ id: 'legacy-tag', severity: 'error', re: /<!--\s*(IF|ELSEIF|ELSE|ENDIF|BEGIN|BEGINELSE|END|INCLUDE\w*|DEFINE|UNDEFINE|EVENT)\b/, msg: 'legacy <!-- ... --> template tag; use Twig {% %}' },
{ id: 'legacy-var', severity: 'error', re: /(?<!\{)\{(L_|LA_|S_|U_|T_)?[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)?\}(?!\})/, msg: 'legacy {VAR} output; use {{ VAR }} / {{ lang() }}' },
{ id: 'define', severity: 'error', re: /\{%-?\s*(DEFINE|UNDEFINE)\b/, msg: 'DEFINE; use {% set %}' },
{ id: 'definition', severity: 'error', re: /\bdefinition\.(?!STYLESHEETS\b|SCRIPTS\b)\w+/, msg: 'definition.X; read the {% set %} variable instead' },
{ id: 'legacy-operator', severity: 'error', re: /\{%.*?\s(eq|neq|ne|gt|lt|gte|lte|mod)\s.*?%\}/, msg: 'legacy comparison operator; use == != > < >= <= %' },
{ id: 'extension-var', severity: 'error', re: new RegExp(`\\b(${EXTENSION_VARS.join('|')})\\b`), msg: 'extension-owned variable; the extension should inject it via a template event' },
// Cleanup rules, enforced once #76 lands.
{ id: 'trailing-whitespace', severity: 'warning', re: /[ \t]+$/, msg: 'trailing whitespace' },
{ id: 'space-indent', severity: 'warning', re: /^ {2,}\S/, msg: 'space indentation; use tabs' },
];

const counts = { error: 0, warning: 0 };
for (const file of readdirSync(dir).filter(f => f.endsWith('.html')).sort()) {
const lines = readFileSync(join(dir, file), 'utf8').split(/\r?\n/);
lines.forEach((line, i) => {
for (const rule of RULES) {
const match = line.match(rule.re);
if (match) {
counts[rule.severity]++;
console.log(`${join(dir, file)}:${i + 1} ${rule.severity} ${rule.id} ${rule.msg} [${match[0].trim().slice(0, 50)}]`);
}
}
});
}

console.log(`${counts.error} error(s), ${counts.warning} warning(s)`);
process.exit(counts.error ? 1 : 0);
75 changes: 75 additions & 0 deletions lint/validate-pages.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Validates rendered board pages with the W3C Nu HTML Checker (the engine behind validator.w3.org).
// Needs a running board with this style installed, and Java (vnu-jar downloads one if missing).
// Usage: BOARD_URL=http://localhost/forum/ STYLE_ID=3 npm run validate
// BOARD_URL board root, default http://localhost/avathar/forum/
// STYLE_ID optional; appends &style=N, which phpBB honours only while override_user_style is off
// Errors only: the checker's info notes (e.g. trailing slashes on void elements) are left out.
// Board content and third-party extensions show up too; judge each error before blaming the style.
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import vnuJar from 'vnu-jar';

const board = (process.env.BOARD_URL ?? 'http://localhost/avathar/forum/').replace(/\/?$/, '/');
const style = process.env.STYLE_ID;
// Some boards answer non-browser user agents with an interstitial.
const headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' };

const url = page => {
const u = new URL(page, board);
if (style) {
u.searchParams.set('style', style);
}
return u.href;
};

const get = async page => {
const res = await fetch(url(page), { headers });
if (!res.ok) {
throw new Error(`${url(page)}: HTTP ${res.status}`);
}
return res.text();
};

// Pick a forum and a topic the guest can see, so no board-specific ids are hard-coded.
// Categories and empty forums have no topics, so try forums until one does.
const ids = (html, script, param) => [...new Set([...html.matchAll(new RegExp(`${script}\\?(?:[^"]*?&amp;)?${param}=(\\d+)`, 'g'))].map(m => m[1]))];

const pages = { 'index.php': await get('index.php') };
for (const forum of ids(pages['index.php'], 'viewforum\\.php', 'f')) {
const html = await get(`viewforum.php?f=${forum}`);
const [topic] = ids(html, 'viewtopic\\.php', 't');
if (topic) {
pages[`viewforum.php?f=${forum}`] = html;
pages[`viewtopic.php?t=${topic}`] = await get(`viewtopic.php?t=${topic}`);
pages[`posting.php?mode=smilies&f=${forum}`] = await get(`posting.php?mode=smilies&f=${forum}`);
break;
}
}
for (const page of ['search.php', 'memberlist.php?mode=team', 'ucp.php?mode=login']) {
pages[page] = await get(page);
}

const dir = mkdtempSync(join(tmpdir(), 'pbtech-validate-'));
const files = {};
for (const [page, html] of Object.entries(pages)) {
const file = join(dir, page.replace(/[^a-z0-9]+/gi, '_') + '.html');
writeFileSync(file, html);
files[`file:${file}`] = page;
}

const vnu = spawnSync('java', ['-jar', String(vnuJar), '--errors-only', '--format', 'json', ...Object.keys(files).map(f => f.slice(5))], { encoding: 'utf8' });
if (vnu.error || !vnu.stderr.trim().startsWith('{')) {
console.error(vnu.error?.message ?? vnu.stderr);
process.exit(2);
}

const messages = JSON.parse(vnu.stderr).messages;
for (const m of messages) {
const page = files[m.url.replace(/^file:\/private/, 'file:')] ?? m.url;
console.log(`${page}:${m.lastLine} ${m.message}`);
}
console.log(`${messages.length} error(s) on ${Object.keys(pages).length} page(s)`);
process.exit(messages.length ? 1 : 0);
Loading
Loading