Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rule based html validation against hydration #2493

Merged
merged 17 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/devGuide/design/serverSideRendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ Some common mistakes are as such:
If you are unsure what elements are allowed within other elements, or what constitutes invalid HTML in general, a good resource to reference would be the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span).
</box>

<box type="info" seamless>
Modern browsers have robust inbuilt mechanisms to auto-correct common causes of hydration. Therefore, to avoid high false positive rate, Markbind has a minimalist rule-based validation against violations. The code for this is located in `core/src/utils/htmlValidationUtils.ts`.

Please help extend it when violations that cause hydration on browsers are spotted.
</box>

Note that the list only included the common causes of hydration issue that MarkBind developers have ran into. There may be other causes of hydration issue that are not listed here (although unlikely).

{% from "njk/common.njk" import previous_next %}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/Page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { SiteConfig } from '../Site/SiteConfig';
import type { FrontMatter } from '../plugins/Plugin';
import type { ExternalManager } from '../External/ExternalManager';
import { MbNode } from '../utils/node';
import { checkForVueHydrationViolation } from '../utils/htmlValidationUtil';

import { LAYOUT_DEFAULT_NAME } from '../Layout';

Expand Down Expand Up @@ -553,6 +554,7 @@ export class Page {
this.collectHeadingsAndKeywords(pageContent);

content = `<div id="app">${content}</div>`;
checkForVueHydrationViolation(content, this.pageConfig.sourcePath);

// Compile the page into Vue application and outputs the render function into script for browser
const compiledVuePage = await pageVueServerRenderer.compileVuePageAndCreateScript(
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/utils/htmlValidationUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import cheerio from 'cheerio';
import * as logger from './logger';

function logWarningForMissingTbody(rootNode: cheerio.Root, path: string) {
const tables = rootNode('table');
for (let i = 0; i < tables.length; i += 1) {
const table = rootNode(tables[i]);
if (table.find('tbody').length === 0) {
// eslint-disable-next-line max-len
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved
logger.error(`Invalid HTML in ${path}.\n`
+ 'Table must have a tbody tag. Please correct this to avoid vue hydration issues.\n');
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

export function checkForVueHydrationViolation(content: string, path: string) {
const $ = cheerio.load(content);
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved
logWarningForMissingTbody($, path);
}
31 changes: 31 additions & 0 deletions packages/core/test/unit/utils/HtmlValidationUtil.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFileSync } from 'fs';
import * as path from 'path';
import * as logger from '../../../src/utils/logger';
import { checkForVueHydrationViolation } from '../../../src/utils/htmlValidationUtil';

const table_without_tbody = readFileSync(path.resolve(__dirname, 'htmlStr/tableWithoutTbody.txt'), 'utf8');
EltonGohJH marked this conversation as resolved.
Show resolved Hide resolved
const table_with_tbody = readFileSync(path.resolve(__dirname, 'htmlStr/tableWithTbody.txt'), 'utf8');
const correct_html_string = readFileSync(path.resolve(__dirname, 'htmlStr/correctHtml.txt'), 'utf8');

describe('checkForVueHydrationViolation', () => {
it(' should not log an error when the content is correct', () => {
const mockError = jest.spyOn(logger, 'error').mockImplementation(() => {});
checkForVueHydrationViolation(correct_html_string, '/fake/path');
expect(mockError).not.toHaveBeenCalled();
});
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved

it(' should not log an error when a table does not have a tbody tag', () => {
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved
const mockError = jest.spyOn(logger, 'error').mockImplementation(() => {});
checkForVueHydrationViolation(table_with_tbody, '/fake/path');
expect(mockError).not.toHaveBeenCalled();
});

it(' should log an error when a table does not have a tbody tag', () => {
const mockError = jest.spyOn(logger, 'error').mockImplementation(() => {});
yiwen101 marked this conversation as resolved.
Show resolved Hide resolved
checkForVueHydrationViolation(table_without_tbody, '/fake/path');
expect(mockError).toHaveBeenCalled();
const expectedMessange = 'Invalid HTML in /fake/path.\n'
+ 'Table must have a tbody tag. Please correct this to avoid vue hydration issues.\n';
expect(mockError).toHaveBeenCalledWith(expectedMessange);
});
});
86 changes: 86 additions & 0 deletions packages/core/test/unit/utils/htmlStr/correctHtml.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<div id="app">
<header sticky>
<navbar type="dark">
<template #brand><a href="/index.html" title="Home" class="navbar-brand">Your Logo</a></template>
<li><a href="/contents/topic1.html" class="nav-link">Topic 1</a></li>
<li><a href="/contents/topic2.html" class="nav-link">Topic 2</a></li>
<dropdown class="nav-link"><template #header>Topic 3</template>
<li><a href="/contents/topic3a.html" class="dropdown-item">Topic 3a</a></li>
<li><a href="/contents/topic3b.html" class="dropdown-item">Topic 3b</a></li></dropdown>
<template #right><li>
<form class="navbar-form">
<searchbar :data="searchData" placeholder="Search" :on-hit="searchCallback" menu-align-right></searchbar></form></li></template></navbar></header>
<div id="flex-body">
<overlay-source id="site-nav" tag-name="nav" to="site-nav">
<div class="site-nav-top">
<div class="fw-bold mb-2" style="font-size: 1.25rem;">Contents</div></div>
<div class="nav-component slim-scroll">
<site-nav><overlay-source class="site-nav-list site-nav-list-root" tag-name="ul" to="mb-site-nav">
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/index.html">Home 🏠</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic1.html">Topic 1</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic2.html">Topic 2</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)">Topic 3

<div class="site-nav-dropdown-btn-container"><i class="site-nav-dropdown-btn-icon site-nav-rotate-icon" onclick="handleSiteNavClick(this.parentNode.parentNode, false); event.stopPropagation();">
<span class="glyphicon glyphicon-menu-down" aria-hidden="true"></span>
</i></div></div><ul class="site-nav-dropdown-container site-nav-dropdown-container-open site-nav-list">
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3a.html">Topic 3a</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3b.html">Topic 3b</a></div></li>
</ul></li>
</overlay-source>
</site-nav></div></overlay-source>
<div id="content-wrapper">
<breadcrumb></breadcrumb>

<br>
<div class="bg-primary text-white px-2 py-5 mb-4">
<div class="container">
<h1 class="display-5 no-index" id="great-you-ve-just-initialized-a-markbind-site">Great!<br>You've just initialized a MarkBind site.<a class="fa fa-anchor" href="#great-you-ve-just-initialized-a-markbind-site" onclick="event.stopPropagation()"></a></h1>
<p class="lead">Let's get started...</p></div></div>
<hr>
<h2 id="what-just-happened">What just happened?<a class="fa fa-anchor" href="#what-just-happened" onclick="event.stopPropagation()"></a></h2>
<p>You have just initialized a <em>default</em> MarkBind site! It is equipped with a set of core features, including site and page navigation. Additionally, we have included some convenient links to our User Guide, to help you get started quickly and easily.</p>
<box type="tip">
<p>If you were intending to convert an existing GitHub wiki or a docs folder into MarkBind, use the <code class="hljs inline no-lang" v-pre>--convert</code> flag instead. See <a href="https://markbind.org/userGuide/markBindInTheProjectWorkflow.html#converting-existing-project-documentation-wiki" target="_blank">User Guide: MarkBind in the Project Workflow</a> for more information.</p>
<p>If you want to start with a <tooltip><template #content>i.e. without any content</template><em>minimal</em></tooltip> template instead, use the <code class="hljs inline no-lang" v-pre>--template</code> flag with the &quot;minimal&quot; option to initialize a minimal site instead of the default. See <a href="https://markbind.org/userGuide/templates.html" target="_blank">User Guide: Templates</a> for more information.</p></box>
<hr>
<h2 id="navigating-this-site">Navigating this site<a class="fa fa-anchor" href="#navigating-this-site" onclick="event.stopPropagation()"></a></h2>
<p>This <em>default</em> site comes pre-configured with the core <a href="https://markbind.org/userGuide/components/navigation.html#navigation-components" target="_blank">Navigation components</a>: a <tooltip><template #content>Site Navigation</template><strong>siteNav</strong></tooltip>, a <tooltip><template #content>Page Navigation</template><strong>pageNav</strong></tooltip>, a <tooltip><template #content>Navigation Bar</template><strong>NavBar</strong></tooltip>, and a <strong>Search Bar</strong>. To help you get started with the <strong>siteNav</strong>, we have included <tooltip><template #content>Topic 1, Topic 2, Topic 3, Topic 3a, Topic 3b</template>five dummy placeholder pages</tooltip>. The <strong>NavBar</strong> also comes with a placeholder slot for your custom Logo.</p>
<hr>
<h2 id="guide-to-markbind">Guide to MarkBind<a class="fa fa-anchor" href="#guide-to-markbind" onclick="event.stopPropagation()"></a></h2>
<p>To see the capability of MarkBind in action, feel free to take a look at some of the websites built using MarkBind on our <a href="https://markbind.org/showcase.html" target="_blank">Showcase</a> page.</p>
<p>For more information on how to work with MarkBind sites and to add content, refer to our comprehensive <a href="https://markbind.org/userGuide/gettingStarted.html" target="_blank">User Guide</a>.</p>
<box type="info">
<p>If you are interested in contributing to MarkBind, you can refer to our <a href="https://markbind.org/devdocs/devGuide/devGuide.html" target="_blank">Developer Guide</a> as well!</p></box>
<panel expanded no-close><template #header><p><strong>Good starting points in our User Guide</strong></p></template>
<h5 id="user-guide-authoring-contents"><strong>User Guide: Authoring Contents</strong><a class="fa fa-anchor" href="#user-guide-authoring-contents" onclick="event.stopPropagation()"></a></h5>
<blockquote>
<p>Learn about the variety of syntax schemes, formats, and custom MarkBind components that you can use in your MarkBind site.</p></blockquote>
<p>More info in: <em><a href="https://markbind.org/userGuide/authoringContents.html" target="_blank">User Guide → Authoring Contents</a></em></p>
<hr>
<h5 id="user-guide-working-with-sites"><strong>User Guide: Working with Sites</strong><a class="fa fa-anchor" href="#user-guide-working-with-sites" onclick="event.stopPropagation()"></a></h5>
<blockquote>
<p>Learn how to modify site properties, apply themes, and enable/disable plugins for your MarkBind site.</p></blockquote>
<p>More info in: <em><a href="https://markbind.org/userGuide/workingWithSites.html" target="_blank">User Guide → Working with Sites</a></em></p>
<hr>
<h5 id="user-guide-full-syntax-reference"><strong>User Guide: Full Syntax Reference</strong><a class="fa fa-anchor" href="#user-guide-full-syntax-reference" onclick="event.stopPropagation()"></a></h5>
<blockquote>
<p>Refer to our Full Syntax Reference page to find a specific feature or component that you want to use in your MarkBind site.</p></blockquote>
<p>More info in: <em><a href="https://markbind.org/userGuide/fullSyntaxReference.html" target="_blank">User Guide → Full Syntax Reference</a></em></p></panel>
<hr>
</div>
<overlay-source id="page-nav" tag-name="nav" to="page-nav">
<div class="nav-component slim-scroll">
<a class="navbar-brand page-nav-title" href="#" v-pre>Topics</a>
<overlay-source id="mb-page-nav" tag-name="nav" to="mb-page-nav" class="nav nav-pills flex-column my-0 small no-flex-wrap">
<a class="nav-link py-1" href="#what-just-happened" v-pre>What just happened?&#x200E;</a>
<a class="nav-link py-1" href="#navigating-this-site" v-pre>Navigating this site&#x200E;</a>
<a class="nav-link py-1" href="#guide-to-markbind" v-pre>Guide to MarkBind&#x200E;</a>

</overlay-source>
</div></overlay-source>
<scroll-top-button></scroll-top-button></div>
<footer>

<div class="text-center">
<small>[Generated by <a href="https://markbind.org/">MarkBind 5.4.0</a>]</small></div></footer></div>
54 changes: 54 additions & 0 deletions packages/core/test/unit/utils/htmlStr/tableWithTbody.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<div id="app">
<header sticky>
<navbar type="dark">
<template #brand><a href="/index.html" title="Home" class="navbar-brand">Your Logo</a></template>
<li><a href="/contents/topic1.html" class="nav-link">Topic 1</a></li>
<li><a href="/contents/topic2.html" class="nav-link">Topic 2</a></li>
<dropdown class="nav-link"><template #header>Topic 3</template>
<li><a href="/contents/topic3a.html" class="dropdown-item">Topic 3a</a></li>
<li><a href="/contents/topic3b.html" class="dropdown-item">Topic 3b</a></li></dropdown>
<template #right><li>
<form class="navbar-form">
<searchbar :data="searchData" placeholder="Search" :on-hit="searchCallback" menu-align-right></searchbar></form></li></template></navbar></header>
<div id="flex-body">
<overlay-source id="site-nav" tag-name="nav" to="site-nav">
<div class="site-nav-top">
<div class="fw-bold mb-2" style="font-size: 1.25rem;">Contents</div></div>
<div class="nav-component slim-scroll">
<site-nav><overlay-source class="site-nav-list site-nav-list-root" tag-name="ul" to="mb-site-nav">
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/index.html">Home 🏠</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic1.html">Topic 1</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic2.html">Topic 2</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)">Topic 3

<div class="site-nav-dropdown-btn-container"><i class="site-nav-dropdown-btn-icon site-nav-rotate-icon" onclick="handleSiteNavClick(this.parentNode.parentNode, false); event.stopPropagation();">
<span class="glyphicon glyphicon-menu-down" aria-hidden="true"></span>
</i></div></div><ul class="site-nav-dropdown-container site-nav-dropdown-container-open site-nav-list">
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3a.html">Topic 3a</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3b.html">Topic 3b</a></div></li>
</ul></li>
</overlay-source>
</site-nav></div></overlay-source>
<div id="content-wrapper">
<breadcrumb></breadcrumb>
<table class="table">
<tbody>
<tr>
<th>Task ID</th>
<th>Task</th>
<th>Estimated Effort</th>
<th>Prerequisite Task</th></tr>
<tr>
<td>E</td>
<td>Planning for next version</td>
<td>1 man day</td>
<td>D</td></tr></tbody></table>
</div>
<overlay-source id="page-nav" tag-name="nav" to="page-nav">
<div class="nav-component slim-scroll">
</div></overlay-source>
<scroll-top-button></scroll-top-button></div>
<footer>

<div class="text-center">
<small>[Generated by <a href="https://markbind.org/">MarkBind 5.4.0</a>]</small></div></footer></div>
53 changes: 53 additions & 0 deletions packages/core/test/unit/utils/htmlStr/tableWithoutTbody.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<div id="app">
<header sticky>
<navbar type="dark">
<template #brand><a href="/index.html" title="Home" class="navbar-brand">Your Logo</a></template>
<li><a href="/contents/topic1.html" class="nav-link">Topic 1</a></li>
<li><a href="/contents/topic2.html" class="nav-link">Topic 2</a></li>
<dropdown class="nav-link"><template #header>Topic 3</template>
<li><a href="/contents/topic3a.html" class="dropdown-item">Topic 3a</a></li>
<li><a href="/contents/topic3b.html" class="dropdown-item">Topic 3b</a></li></dropdown>
<template #right><li>
<form class="navbar-form">
<searchbar :data="searchData" placeholder="Search" :on-hit="searchCallback" menu-align-right></searchbar></form></li></template></navbar></header>
<div id="flex-body">
<overlay-source id="site-nav" tag-name="nav" to="site-nav">
<div class="site-nav-top">
<div class="fw-bold mb-2" style="font-size: 1.25rem;">Contents</div></div>
<div class="nav-component slim-scroll">
<site-nav><overlay-source class="site-nav-list site-nav-list-root" tag-name="ul" to="mb-site-nav">
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/index.html">Home 🏠</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic1.html">Topic 1</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)"><a href="/contents/topic2.html">Topic 2</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-0" onclick="handleSiteNavClick(this)">Topic 3

<div class="site-nav-dropdown-btn-container"><i class="site-nav-dropdown-btn-icon site-nav-rotate-icon" onclick="handleSiteNavClick(this.parentNode.parentNode, false); event.stopPropagation();">
<span class="glyphicon glyphicon-menu-down" aria-hidden="true"></span>
</i></div></div><ul class="site-nav-dropdown-container site-nav-dropdown-container-open site-nav-list">
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3a.html">Topic 3a</a></div></li>
<li><div class="site-nav-default-list-item site-nav-list-item-1" onclick="handleSiteNavClick(this)"><a href="/contents/topic3b.html">Topic 3b</a></div></li>
</ul></li>
</overlay-source>
</site-nav></div></overlay-source>
<div id="content-wrapper">
<breadcrumb></breadcrumb>
<table class="table">
<tr>
<th>Task ID</th>
<th>Task</th>
<th>Estimated Effort</th>
<th>Prerequisite Task</th></tr>
<tr>
<td>E</td>
<td>Planning for next version</td>
<td>1 man day</td>
<td>D</td></tr></table>
</div>
<overlay-source id="page-nav" tag-name="nav" to="page-nav">
<div class="nav-component slim-scroll">
</div></overlay-source>
<scroll-top-button></scroll-top-button></div>
<footer>

<div class="text-center">
<small>[Generated by <a href="https://markbind.org/">MarkBind 5.4.0</a>]</small></div></footer></div>