「textlint と Next.js で始める静的サイトの校正支援」は Next.js のプレビューモードを前提としています。ここでは、すでにプレビューモードが実装されている場合、どのように処理を追加していくか等を記述しています。
チェック処理を追加する場所
以下のような理由により、できるだけサーバーで実行されるコードへ追加します。
- チェック処理はコンテンツ表示に直接関係するものではないので、表示時の負荷が増えないようにしたい
- 許可ワードのリストに「発表前の製品名などが含まれる可能性」等を考慮し、処理用のコードはブラウザー側から見えないようにしたい(「draftlint」は GitHub 上で公開しているのでどちらにしても設定は見えてしまいますが)
Next.js のプレビューモードを利用するなら、各ページのgetStaticProps()
でプレビューページ用のデータを組み立てる場所に挿入すると都合が良いです。
getStaticProps()
内なら API route(Function) で設定された「プレビューモード用のデータ」と CMS から取得された「ページの属性(カテゴリー等)」を利用できるため、適用するルールの設定等も容易に行えます。
今回は各ページ(GET API)のコンテンツ取得とデータの組み立てを 1 つの関数getPagesData()
で実施しているので、その中に追加しています。
hankei6km/draftlint draftlint/pages/docs/[id].tsx 等
export const getStaticProps: GetStaticProps = async (context) => {
const pageData = await getPagesData('docs', context);
return {
props: {
pageData
}
};
};
export const getStaticProps: GetStaticProps = async (context) => {
const pageData = await getPagesData('pages', {
...context,
params: { id: 'home' }
});
const items = await getSortedPagesData('docs', {
filters: 'displayOnIndexPage[equals]true'
});
return { props: { pageData, items } };
};
textlintモジュールの種類
textlint はプリセット(ルールのセット)がサポートされていて、多くの場合はプリセットの調整(設定)を行って使う方法が一般的なようです。よって、モジュールとして利用するなら Engine を選択した方が良いのですが、プリセット等は動的にロードされるためバンドルの対象になりにくいという問題があります。
これについてはいくつか対処方法がありますが、今回はプリセット等を明示的にrequire
でロードし、Kernel の options として展開するユーティリティーを用意しました。
また、これに伴い textlint のモジュールとしては Core(Kernel) を利用しています。
hankei6km/draftlint/utils/textlint.ts から一部抜粋
// https://github.com/mobilusoss/textlint-browser-runner/tree/master/packages/textlint-bundler
export function getTextlintKernelOptions(
draftLintOptions?: DraftLintOptions
): TextlintKernelOptions {
const {
presets = undefined,
rules = undefined,
ruleOptions = undefined,
filterRules = undefined
}: DraftLintOptions = draftLintOptions || {};
// preset の ruleId の扱い.
// << snip >>
// https://github.com/textlint/textlint/blob/be0b48c1a83713ee7b649447d7580c42ffca9ace/packages/textlint/src/engine/textlint-module-loader.ts#L135
// https://github.com/textlint/textlint/blob/be0b48c1a83713ee7b649447d7580c42ffca9ace/packages/textlint/src/engine/textlint-module-mapper.ts#L20
// https://github.com/textlint/textlint/issues/299
// preset を複数扱う場合にはどこからエラーが発生したのか識別できるように
// ruleId は上記に則って付与する。ただし手動とする。
const _presets = presets || [
{
presetId: 'ja-technical-writing',
preset: require('textlint-rule-preset-ja-technical-writing')
},
{
presetId: 'jtf-style',
preset: require('textlint-rule-preset-jtf-style')
}
];
// << snip >>
const options = {
// filePath: '/path/to/file.md',
ext: '.html',
plugins: [
{
pluginId: 'html',
plugin: require('textlint-plugin-html')
}
],
rules: _presets
.map((p) =>
Object.entries(p.preset.rules).map<TextlintKernelRule>(([k, v]) => {
const ruleId = `${p.presetId}/${k}`;
return {
ruleId: ruleId,
rule: v as TextlintKernelRule['rule'],
options:
_ruleOptions[ruleId] !== undefined
? _ruleOptions[ruleId]
: p.preset.rulesConfig[k] !== undefined
? p.preset.rulesConfig[k]
: {}
};
})
)
.reduce((a, v) => a.concat(v), [])
.concat(_rules),
filterRules: _filterRules
};
return options;
}
HTML ソースの前処理
CMS が出力する HTML の内容によって対応が変わりますが、microCMS を利用する場合は以下のようなことを考慮し、前処理を実施する必要があります。
パラグラフと<br>
リッチエディタでは(markdown でパラグラフを分割するように)空行を挿入すると、設定により空行分の「<br>
が連続した状態」か「<b><br></p>
が連続した状態」になります。とくに <br>
の連続は「画面上の表示では文末に見える部分」がルールによっては文末として判定されないので、対応が必要となります。
また <p><br></p>
については試した範囲では問題は見られませんでしたが、markdown であまり見られない組み合わせなので、ルールによっては影響が出る可能性もあります。
よって、今回は「<br>
が連続していた場合にパラフラフを分割」「空のパラグラフは取り除く」処理を入れています。
なお、上記の処理はほぼ不可逆的になるため(元に戻すにはそれなりに手間がかかる)、プレビューと本番の差異をなくすならば、通常のビルド処理にも組み込むことになります(今回作成した処理は簡易版なので実際に使うには調整が必要です)。
この辺は、やりすぎると逆に編集者の利便性を損ねることもあるので、場合によっては「あえて処理しない」という選択肢も残しておく必要はありそうです。
https://github.com/hankei6km/draftlint/lib/html.ts から一部抜粋
export function splitStrToParagraph(html: string): string {
const $ = cheerio.load(html);
$('body')
.children()
.each((_idx, elm) => {
if (elm.type === 'tag' && elm.tagName === 'p') {
const $elm = $(elm);
let $p = cheerio.load('<p></p>')('p');
const $pArray: typeof $p[] = [];
let brCnt = 0;
const $contents = $elm.contents();
$contents.each((idx, e) => {
if (e.type === 'tag' && e.tagName === 'br') {
// <br> を数える
brCnt++;
} else {
if (brCnt === 1) {
// <br> が1つだけあったので、そのまま追加
$p.append($contents.get(idx - 1));
} else if (brCnt >= 2) {
// <br> が複数存在していたので <p> として分割
$pArray.push($p);
$p = cheerio.load('<p></p>')('p');
}
$p.append($(e));
brCnt = 0;
}
});
$pArray.push($p);
$elm.replaceWith(
$pArray
.filter(($p) => $p.contents().length > 0)
.map(($p) => $p.parent().html())
.join('')
);
}
});
return $('body').html() || '';
}
HTML を 1 つにまとめる
textlint では context が文章毎になるため「リッチエディタを使いつつ一部はHTMLで入稿する」ような対応をしている場合は、HTML を 1 つにまとめてからチェックを行い、状況によっては再分割する必要があります。
今回はリッチエディタは 1 つしか配置していないのでとくに処理はしていませんが、別のサイトでは対応しているので追記するかもしれません。
チェック結果の加工
textlint のチェック結果は「エラー箇所を HTML ソースコードの行番か先頭からの位置で表している」ために、いわゆる WYSIWYG なエディターで作成された文章のプレビュー表示との相性は良くない情報となっています。
今回は少々乱暴ですがメッセージを HTML ソースへ直接挿入し、あわせて挿入位置のアンカー一覧のリスト(HTML)を作成することで対応しています。textlint では AST でチェックする対象を絞り込んでいるので、不用意に属性の文字列等に挿入されることはないだろうという想定です。文字参照を分割してしまうことがあったのでhtml-to-ast
を使って挿入位置を少し調整しています。
hankei6km/draftlint/lib/draftlint.ts から一部抜粋
export async function textLintInHtml(
html: string,
options?: TextlintKernelOptions,
messageStyle: { [key: string]: string } = { color: 'red' },
idPrefix: string = ''
): Promise<TextLintInHtmlResult> {
let ret: TextLintInHtmlResult = { html: '', messages: [], list: '' };
// https://github.com/mobilusoss/textlint-browser-runner/tree/master/packages/textlint-bundler
const kernel = new TextlintKernel();
const results = [
await kernel.lintText(
html,
// todo: options(presets など)は SiteServerSideConfig で定義できるようにする.
options || getTextlintKernelOptions()
)
];
if (results && results.length > 0 && results[0].messages.length > 0) {
const insInfos = getInsInfos(html, results[0].messages);
const $wrapper = cheerio.load('<span/>')('span');
Object.entries(messageStyle).forEach(([k, v]) => {
$wrapper.css(k, v);
});
let pos = 0;
results[0].messages.forEach((m, i) => {
const id = `${idPrefix}:textLintMessage:${i}`;
$wrapper.attr('id', id);
const insertHtml = $wrapper.html(m.message).parent().html();
if (m.message && insertHtml) {
const index = insInfos[i].insIndex + 1;
ret.html = `${ret.html}${html.slice(pos, index)}${insertHtml}`;
pos = index;
ret.messages.push({
ruleId: m.ruleId,
message: m.message,
id,
severity: m.severity
});
}
});
ret.html = `${ret.html}${html.slice(pos)}`;
const $dl = cheerio.load('<dl/>')('dl');
ret.messages.forEach((m) => {
const $d = cheerio.load('<dt></dt><dd><a/></dd>');
// https://github.com/textlint/textlint/blob/master/packages/%40textlint/kernel/src/shared/rule-severity.ts
// https://github.com/textlint/textlint/blob/master/packages/%40textlint/kernel/src/context/TextlintRuleSeverityLevelKeys.ts
$d('dt').text(
m.severity === 0 ? 'info' : m.severity === 1 ? 'warning' : 'error'
);
const $a = $d('a');
$a.attr('href', `#${m.id}`);
$a.text(`${m.message}(${m.ruleId})`);
$dl.append($d('body').children());
});
ret.list = $dl.parent().html() || '';
}
return ret;
}
チェック結果の表示
冒頭の記述とも重複しますが、表示時の処理を増やさないならば、上記で作成した HTML を単純にページ上に挿入することが望ましいです。
しかし、今回は通知用のコンポーネントを作成しメッセージを通知しています。通知用のコンポーネントは他の用途にも使えるようにしているので、「メッセージがない実際の表示状態を確認」等の利便性を考慮すると、この辺が妥協点だと考えています。
hankei6km/draftlint/components/Notification.tsx から一部抜粋
const Notification = (others: Omit<ContentProps, 'onClose'> & {}) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
useEffect(() => {
enqueueSnackbar(`${others.title}:${others.messageHtml}`, {
preventDuplicate: true,
persist: true,
anchorOrigin: {
vertical: 'top',
horizontal: 'center'
},
content: (key, _message) => (
<div id={`${key}`}>
<Content
{...others}
onClose={() => {
closeSnackbar(key);
}}
/>
</div>
)
});
}, [enqueueSnackbar, closeSnackbar, others]);
return <></>;
};
export default Notification;
その他
タイムアウト対応
Vercel の Hobby プランで利用する場合、プレビューモードで文章のチェックを実行するとタイムアウトの制限(10 秒)に引っかかりやすくなります。
「draftlint」側の無駄なコードを見直す必要もありますが、とりあえずはプリセット間で重複しているルール等を見直すことで対応しています。利用するルールを試したときのメモは「付録: options の調整」として残してあります。
プラン的に余裕があるなら Vercel の設定を変更する方法もありますが、functions
の定義を追加すると Vercel でのビルド時に警告が出ます。ルールを減らすことで対応はできているので、今回は設定を見送りました。
WARNING: Your application is being opted out of "@vercel/next" optimized lambdas mode due to `functions` config.
{
"functions": {
"pages/index.tsx": {
"memory": 1024,
"maxDuration": 10
},
"pages/docs/index.tsx": {
"memory": 1024,
"maxDuration": 10
},
"pages/docs/*.tsx": {
"memory": 1024,
"maxDuration": 10
}
}
}
なお、「Next.js のサーバーでレンダリングされるページは厳密には Serverless Functions ではない=制限が異なる」という解釈もありそうですが、あまりやりすぎるとフェアユースからはみ出してしまいそうなので深くは追及していません。
NOTE: Next.js bundles all API Routes and server-rendered pages into shared Serverless Functions , separating API routes and server-rendered pages when deployed on Vercel. Further Serverless Functions are created only if they do not fit within the 50MB limit. As a result, the "Serverless Functions per Deployment" limit is unlikely to apply for Next.js projects.