diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dc1a02a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "biome.enabled": false +} diff --git a/AGENTS.md b/AGENTS.md index 3b9e9ee..d7be851 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,13 @@ - `src/app.ts`: ブログSPAの共通起動処理。 - `src/route.ts`: URL状態から表示ページを決めて描画する。 - `src/reroute.ts`: `popstate`、検索フォーム、内部リンククリックを監視して再ルーティングする。 +- `src/state.ts`: `pathname` / `search` / `hash` から記事ID・タグ・検索語を取り出す。 +- `src/render.ts`: `#root` 描画、canonical更新、開発時の内部リンク化、スクロールを扱う。 +- `src/notify.ts`: `about.json` の更新情報から通知表示を扱う。 - `src/ssg.ts`: `post.template.html` と `posts.json` から `posts/*.html` を生成する。 -- `generatePosts.js`: `markdown/*.md` から `posts.json` を更新する。 -- `generateBibliography.js`: `works.json` に文献表記を付与する。 -- `generateSitemap.js`: `sitemap.xml` を生成する。 +- `src/scripts/postsJson.ts`: `markdown/*.md` から `posts.json` を更新する。 +- `src/scripts/worksJson.ts`: `works.json` に文献表記を付与する。 +- `src/scripts/sitemap.ts`: `sitemap.xml` を生成する。 ## HTMLエントリー @@ -25,14 +28,15 @@ webpackのentryは `index`、`works`、`hydrate` の3種類。 ### `index.html` -> `src/index.ts` - ブログ本体のSPAエントリー。 -- `src/index.ts` は `app()` を呼ぶだけ。`app(true)` は `./posts.json` / `./about.json` を読み、初回に `route(posts)` で `#root` を描画する。 +- `src/index.ts` は `app()` を呼ぶだけ。`app()` は `./posts.json` / `./about.json` を読み、初回に `route(posts)` で `#root` を描画する。 ### `works.html` -> `src/works/index.ts` - 研究業績ページ用の独立エントリー。 - `./works.json` を読み、`src/works/Works.ts` でHTMLを生成して `renderRoot()` に渡す。 - ブログSPAの `route.ts` は使わない。 -- 文献表記は `generateBibliography.js` が `works.json` に事前生成する。 +- `about.json` も読み、`notifyUpdate()` で更新通知を表示する。 +- 文献表記は `src/scripts/worksJson.ts` が `works.json` に事前生成する。 ### `posts/[id].html` -> `src/hydrate.ts` @@ -43,6 +47,7 @@ webpackのentryは `index`、`works`、`hydrate` の3種類。 ## 生成物 - `dist/*.js` と `dist/css/bundle.css` は公開対象のビルド成果物。 +- 開発サーバーではwebpack-dev-middlewareが `dist/dev/*.js` 相当を配信し、`/dist/*.js` からリダイレクトする。 - `posts/*.html` はSSG成果物。 - `posts.json`、`works.json`、`sitemap.xml` も生成スクリプトで更新されることがある。 - 生成物の差分が大量に出る場合は、変更理由を確認してから扱う。無関係なら巻き戻さない。 @@ -51,21 +56,24 @@ webpackのentryは `index`、`works`、`hydrate` の3種類。 `package.json` のscripts: -- `npm run dev`: 開発サーバー起動。起動時に記事・サイトマップ・文献・静的記事HTMLも生成する。 +- `npm run dev`: Express開発サーバー起動。起動時に記事・サイトマップ・文献・静的記事HTMLも生成する。 - `npm run tsc`: TypeScript型チェックのwatch。 +- `npm run tsc:once`: TypeScript型チェックの単発実行。 - `npm run build`: webpackで `dist/index.js`、`dist/hydrate.js`、`dist/works.js` を生成。 - `npm run build-css`: CSSを `dist/css/bundle.css` に生成。 -- `npm run lint`: TypeScriptのESLint修正。 +- `npm run lint`: TypeScriptのESLint確認。 +- `npm run lint:fix`: TypeScriptのESLint自動修正。 - `npm run lint-css`: CSS lint。 +- `npm run lint-css:fix`: CSS lintの自動修正。 - `npm run fmt`: Prettier整形。 -`tsc` と `tsc-js` は `-w` 付きなので、単発確認では `./node_modules/.bin/tsc --noEmit` などを使う。 +`tsc` は `-w` 付きなので、単発確認では `npm run tsc:once` を使う。 ## Code App / iOS 環境 - iOSの[Code App](https://code.thebaselab.com/)でも開発サーバーを動かすことがある。公式ドキュメント上も、Code AppはiOSの制約を受け、ネイティブコンポーネントを含むモジュールの追加やsubprocess起動ができない。 - Code AppのNode.js v18.19.0では、`process.setUncaughtExceptionCaptureCallback()` が有効な状態で起動することがある。この環境では `domain` モジュールと共存できず、`ts-node/register` は `ERR_DOMAIN_CALLBACK_NOT_AVAILABLE` で落ちる。サーバー側TSの読み込みには `jiti` を使う。 -- Code AppのNode環境では `WebAssembly` が未定義になることがある。webpackの既定 `md4` ハッシュ経路は `webpack/lib/util/hash/md4.js` に入り `ReferenceError: WebAssembly is not defined` になるため、`webpack.config.js` の `output.hashFunction: 'sha256'` を外さない。 +- Code AppのNode環境では `WebAssembly` が未定義になることがある。webpackの既定 `md4` ハッシュ経路は `webpack/lib/util/hash/md4.js` に入り `ReferenceError: WebAssembly is not defined` になるため、`webpack.config.mjs` の `output.hashFunction: 'sha256'` を外さない。 - Node 18 / OpenSSL 3 では古いwebpackの `md4` ハッシュが `digital envelope routines` / `ERR_OSSL_EVP_UNSUPPORTED` で落ちる。webpack系依存を古い版へ戻す場合はCode Appで `npm run dev` を再検証する。 - Code Appでは、Nodeの `child_process.spawn()` から子プロセスを作る処理が `spawn EPERM` / `Operation not permitted` で落ちることがある。内蔵ターミナルがコマンドを起動できても、その中のNodeプロセスからさらに別プロセスを起動できるとは限らない。Code Appで動かすツールは、外部CLIや複数コマンドを内部から起動しない構成を優先する。 @@ -80,6 +88,7 @@ webpackのentryは `index`、`works`、`hydrate` の3種類。 - `src/ssg.ts` はサーバーサイド専用。クライアント専用APIに依存するコードをimportしない。 - 予約投稿は本番相当では `excludeReserved` で除外される。開発時はlocalhost判定で表示される。 - `blog-card` はCustom Element。SSGではカードHTMLを事前埋め込みし、SPAでは `defineBlogCard` が定義する。 +- ESLint設定は `eslint.config.mjs` に集約されている。`import/order` はwarnで有効。 ## 編集方針 @@ -92,6 +101,8 @@ webpackのentryは `index`、`works`、`hydrate` の3種類。 ## コードレビューの基本方針 +- GitHub PR上でレビューする場合は、まとめてコメントするだけで済ませず、当該の変更行へGitHubのPR上でインラインコメントを残してください。 +- レビュー依頼では、断りなくコミット・pushを行わないでください。レビューはレビューだけに留め、修正案はコメントとして提示してください。 - ベストプラクティスの具体例を提示してください - 学習リソースの提案を積極的に行ってください - 以下のプレフィックスを使用してレビューコメントを分類してください: diff --git a/README.md b/README.md index 78e105a..3b5d7c9 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ - 依存パッケージ0、ビルドサイズを10KB未満まで削減 - webpack・PostCSS([v1.1.0](https://github.com/kyonenya/kyonenya.github.io/releases/tag/v1.1.0)より) - TypeScript([v2.0.0](https://github.com/kyonenya/kyonenya.github.io/releases/tag/v2.0.0)より) -- [Codesandbox for iOS](https://codesandbox.io/codesandbox-for-ios) というiPadでNode.jsが動かせるアプリで開発 +- [Code App](https://code.thebaselab.com/) というiPadでNode.jsが動かせるアプリで開発 ## なぜそんな設計で作るのですか(v0.0.0) diff --git a/dist/hydrate.js b/dist/hydrate.js index 26c8de8..24935a7 100644 --- a/dist/hydrate.js +++ b/dist/hydrate.js @@ -1 +1 @@ -(()=>{"use strict";var e={224(e,t){t.ur=t.Ks=t.xq=t.xS=void 0,t.xS=e=>(t,n)=>{if(void 0===n)return;const{maxLength:o=50,beforeLength:r=20}=null!=e?e:{},s=t.indexOf(n);if(-1===s)return;const a=s-r<=0?0:s-r,i=s+n.length,l=a+o;return{isBeforeEllipsed:0!==a,beforeText:t.substring(a,s),keyword:t.substring(s,i),afterText:t.substring(i,l),isAfterEllipsed:l(0,t.xS)(o)(e,n),t.Ks=e=>(n,o)=>{const r=(0,t.xq)(n,o,e);if(void 0===r)return;const{ellipsisToken:s="...",keywordModifier:a=e=>e}=null!=e?e:{};return(r.isBeforeEllipsed?s:"")+r.beforeText+a(r.keyword)+r.afterText+(r.isAfterEllipsed?s:"")},t.ur=(e,n,o)=>(0,t.Ks)(o)(e,n)}},t={};const n=e=>`#${e}`,o=(e,t)=>`\n
  • \n \n ${e===t?`${n(e)}`:n(e)}\n \n
  • `,r=(e,t)=>`\n \n`,s=864e5,a=30*s,i={seconds:1e3,minutes:6e4,hours:36e5,day:s,month:a,year:12*a},l=Object.keys(i);function c(e,t){return e/i[t]}function d(e){return{seconds:e.getSeconds(),minutes:e.getMinutes(),hours:e.getHours(),day:e.getDate(),month:e.getMonth()+1,year:e.getFullYear()}}function u(e){return String(e).padStart(2,"0")}function p(e){const{year:t,month:n,day:o}=d(e);return`${t}-${u(n)}-${u(o)}`}function h(e,t){return e.getTime()Math.abs(c(t,e)){e.href="/"+e.href.replace(y,"")}),document.title=e.title,t.innerText=e.suffix??"",e.description&&(n.content=e.description),e.href){document.querySelectorAll('link[rel="canonical"]').forEach(e=>e.remove());const t=document.createElement("link");t.rel="canonical",t.href=e.href,document.head.appendChild(t)}}const v=e=>{document.getElementById(e)?.scrollIntoView({behavior:"smooth"})};function x(e,t,n){const o=/\/posts\/(\d+)/.exec(t)?.[1],r=o?Number(o):void 0,s=new URLSearchParams(e),a=s.get("tag"),i=s.get("id"),l=i&&/^\d+$/.test(i)?Number(i):void 0;return{id:r??l,legacyId:l,tag:a??void 0,keyword:void 0===n||""===n?void 0:decodeURIComponent(n.replace("#",""))}}const L=e=>e.replace(/——(?![^(]*\))/g,'——'),k=(e,t)=>{return`\n
    \n
    \n
    \n \n
    \n
    \n ${e.title?`

    ${L(e.title)}

    `:""}\n ${(e=>e.replace(/——/g,"──").replace(/([!?]) /g,(e,t)=>`${t} `).replace(/

    ([「『(].+?)<\/p>/g,(e,t)=>`

    ${t}

    `))((n=e.text,n.replace(/(.+?)<\/a>/g,(e,t,n,o)=>(e=>{const{href:t,content:n,attributes:o=""}=e;return`${n}`})({href:t,attributes:n,content:o}))))}\n
    \n
    \n \n ${t?p(e.createdAt):m(e.createdAt)}\n \n ${r(e.tags)}\n
    \n
    \n
    `;var n};var S=function n(o){var r=t[o];if(void 0!==r)return r.exports;var s=t[o]={exports:{}};return e[o](s,s.exports,n),s.exports}(224);const T=e=>`${e}`,A=e=>{const{post:t,tag:n,keyword:o}=e,s=(0,S.ur)(t.plainText,o,{maxLength:200,beforeLength:48,keywordModifier:T});return`\n \n \n
    \n \n
    \n ${t.title?((e,t)=>`\n

    \n ${L(((e,t)=>t?e.replace(t,T(t)):e)(e,t))}\n

    `)(t.title,o):""}\n ${s?(e=>`\n
    \n

    \n ${e}\n

    \n
    `)(s):(a=t.plainText,`\n
    \n

    \n ${a.substring(0,300)}\n

    \n
    `)}\n
    \n
    \n \n ${m(t.createdAt)}\n \n ${r(t.tags,n)}\n
    \n `;var a},E=(e,t)=>`\n
    \n ${t?`

    ${t}

    `:""}\n
      \n ${e}\n
    \n
    `,D=e=>E(e.map((t,n)=>A({post:e[n]})).reverse().join("")),q=(e,t)=>E(e.map((n,o)=>n.tags.includes(t)?A({post:e[o],tag:t}):"").reverse().join(""),`#${t}`),I=(e,t,n)=>E(e.map((o,r)=>void 0===n||o.tags.includes(n)?A({post:e[r],tag:n,keyword:t}):"").reverse().join(""),`「${t}」`),j=document.querySelector(".el_search_input");function C(e){const{id:t,legacyId:n,tag:o,keyword:r}=x(window.location.search,window.location.pathname,window.location.hash),s=b(window.location.href)?e:function(e){return e.filter(e=>h(e.createdAt,new Date))}(e);if((e=>{window.scrollTo(0,0),j&&(j.style.display="block",e&&window.history.replaceState(void 0,"",`/posts/${e}`))})(n),void 0!==t){const e=s.find(e=>e.id===t);if(!e)return;(e=>{w((e=>({body:k(e,void 0),title:e.title?`${e.title}|placet experiri :: ${e.id}`:`placet experiri :: ${e.id}`,suffix:` :: ${e.id}`,description:`${e.plainText.substring(0,110)}…`,href:`${y}posts/${e.id}`}))(e)),j&&(j.style.display="none")})(e)}else void 0!==r?((e,t,n)=>{w({body:I(e,t,n),title:`「${t}」|placet experiri`})})(s,r,o):void 0!==o?((e,t)=>{w({body:q(e,t),title:`#${t}|placet experiri`,href:`${y}?tag=${t}`})})(s,o):(e=>{w({body:D(e),title:"placet experiri",href:y})})(s)}document.querySelectorAll(".bl_posts_dateago").forEach(e=>{const t=e.getAttribute("data-date");t&&(e.textContent=m(new Date(t)))}),async function(e=!0){const t=e?".":"..",n=`${t}/posts.json`,r=`${t}/about.json`,s=function(e){return[...e].reverse().map(e=>function(e){return{...e,title:null===e.title||""===e.title?void 0:e.title,plainText:e.text.replace(/
    (.+?)<\/blockquote>/g,(e,t)=>`> ${t}`).replace(/

    (.+?)<\/h2>/g,(e,t)=>`## ${t}`).replace(/——/g,"──").replace(/
    (.+?)<\/div>/g,"").replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,""),createdAt:_(e.createdAt),modifiedAt:_(e.modifiedAt)}}(e))}(await $(n));var a;e&&C(s),function(e){window.addEventListener("popstate",()=>e())}(a=()=>C(s)),function(e){const t=document.querySelector(".el_search_form"),n=document.querySelector(".el_search_input");t?.addEventListener("submit",t=>{t.preventDefault(),window.history.pushState(x(window.location.search,window.location.pathname,`#${n.value}`),"",`${window.location.search}#${n.value}`),e()})}(a),function(e){document.addEventListener("click",t=>{if(!(t.target instanceof Element))return;const n=t.target.closest('a[href^="#"], a[href^="/?"], a[href="/"], a[href^="/posts/"]');n&&(t.preventDefault(),window.history.pushState(void 0,"",n.href),e(),v(n.hash.replace("#","")))})}(a),function(e){class t extends HTMLElement{constructor(){super();const t=this.getAttribute("id");if(!t)return;const n=e.find(e=>e.id===parseInt(t,10));n&&(this.innerHTML=(e=>{return`\n `;var t})(n))}}window.customElements.define("blog-card",t)}(s),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),h(new Date,n)}(new Date(e),t)||t<=0||(n?.addEventListener("click",()=>{localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await $(r))}(!1)})(); \ No newline at end of file +(()=>{"use strict";var e={224(e,t){t.ur=t.Ks=t.xq=t.xS=void 0,t.xS=e=>(t,n)=>{if(void 0===n)return;const{maxLength:s=50,beforeLength:o=20}=null!=e?e:{},r=t.indexOf(n);if(-1===r)return;const a=r-o<=0?0:r-o,i=r+n.length,l=a+s;return{isBeforeEllipsed:0!==a,beforeText:t.substring(a,r),keyword:t.substring(r,i),afterText:t.substring(i,l),isAfterEllipsed:l(0,t.xS)(s)(e,n),t.Ks=e=>(n,s)=>{const o=(0,t.xq)(n,s,e);if(void 0===o)return;const{ellipsisToken:r="...",keywordModifier:a=e=>e}=null!=e?e:{};return(o.isBeforeEllipsed?r:"")+o.beforeText+a(o.keyword)+o.afterText+(o.isAfterEllipsed?r:"")},t.ur=(e,n,s)=>(0,t.Ks)(s)(e,n)}},t={};const n=e=>`#${e}`,s=(e,t)=>`\n
  • \n \n ${e===t?`${n(e)}`:n(e)}\n \n
  • `,o=(e,t)=>`\n
      \n ${e.map(e=>s(e,t)).join("")}\n
    \n`,r=864e5,a=30*r,i={seconds:1e3,minutes:6e4,hours:36e5,day:r,month:a,year:12*a},l=Object.keys(i);function c(e,t){return e/i[t]}function d(e){return{seconds:e.getSeconds(),minutes:e.getMinutes(),hours:e.getHours(),day:e.getDate(),month:e.getMonth()+1,year:e.getFullYear()}}function u(e){return String(e).padStart(2,"0")}function p(e){const{year:t,month:n,day:s}=d(e);return`${t}-${u(n)}-${u(s)}`}function f(e,t){return e.getTime()Math.abs(c(t,e))e.replaceAll(/——(?![^(]*\))/g,'——'),v="https://kyonenya.github.io/";function x(e){const t=document.querySelector(".el_logo_suffix"),n=document.querySelector("meta[name=description]");if(function(e){document.getElementById("root").innerHTML=e;const t=document.getElementById(window.location.hash.replace("#",""));t?t.scrollIntoView({behavior:"smooth"}):window.scrollTo(0,0),b(window.location.href)&&document.querySelectorAll(`a[href^="${v}"]`).forEach(e=>{e.href="/"+e.href.replace(v,"")})}(e.body),document.title=e.title,t.innerText=e.suffix??"",e.description&&(n.content=e.description),e.href){document.querySelectorAll('link[rel="canonical"]').forEach(e=>e.remove());const t=document.createElement("link");t.rel="canonical",t.href=e.href,document.head.appendChild(t)}}const A=(e,t)=>{return`\n
    \n
    \n
    \n \n
    \n
    \n ${e.title?`

    ${w(e.title)}

    `:""}\n ${(e=>e.replaceAll("——","──").replaceAll(/([!?]) /g,(e,t)=>`${t} `).replaceAll(/

    ([「『(].+?)<\/p>/g,(e,t)=>`

    ${t}

    `))((n=e.text,n.replaceAll(/(.+?)<\/a>/g,(e,t,n,s)=>(e=>{const{href:t,content:n,attributes:s=""}=e;return`${n}`})({href:t,attributes:n,content:s}))))}\n
    \n
    \n \n ${t?p(e.createdAt):m(e.createdAt)}\n \n ${o(e.tags)}\n
    \n
    \n
    `;var n};var k=function n(s){var o=t[s];if(void 0!==o)return o.exports;var r=t[s]={exports:{}};return e[s](r,r.exports,n),r.exports}(224);const L=e=>`${e}`,S=e=>{const{post:t,tag:n,keyword:s}=e,r=(0,k.ur)(t.plainText,s,{maxLength:200,beforeLength:48,keywordModifier:L});return`\n \n \n
    \n \n
    \n ${t.title?((e,t)=>`\n

    \n ${w(((e,t)=>t?e.replace(t,L(t)):e)(e,t))}\n

    `)(t.title,s):""}\n ${r?(e=>`\n
    \n

    \n ${e}\n

    \n
    `)(r):(a=t.plainText,`\n
    \n

    \n ${a.substring(0,300)}\n

    \n
    `)}\n
    \n
    \n \n ${m(t.createdAt)}\n \n ${o(t.tags,n)}\n
    \n `;var a},T=(e,t)=>`\n
    \n ${t?`

    ${t}

    `:""}\n
      \n ${e}\n
    \n
    `,E=e=>T(e.map((t,n)=>S({post:e[n]})).reverse().join("")),D=(e,t)=>T(e.map((n,s)=>n.tags.includes(t)?S({post:e[s],tag:t}):"").reverse().join(""),`#${t}`),q=(e,t,n)=>T(e.map((s,o)=>void 0===n||s.tags.includes(n)?S({post:e[o],tag:n,keyword:t}):"").reverse().join(""),`「${t}」`),I=document.querySelector(".el_search_input");function j(e){const{id:t,legacyId:n,tag:s,keyword:o}=_(window.location.search,window.location.pathname,window.location.hash),r=b(window.location.href)?e:function(e){return e.filter(e=>f(e.createdAt,new Date))}(e);if((e=>{null!==e&&window.history.replaceState(void 0,"",`/posts/${e}`)})(n),null!==t){const e=r.find(e=>e.id===t);if(!e)return;(e=>{x((e=>({body:A(e,void 0),title:e.title?`${e.title}|placet experiri :: ${e.id}`:`placet experiri :: ${e.id}`,suffix:` :: ${e.id}`,description:`${e.plainText.substring(0,110)}…`,href:`${v}posts/${e.id}`}))(e)),I&&(I.style.display="none")})(e)}else null!==o?((e,t,n)=>{x({body:q(e,t,n),title:`「${t}」|placet experiri`}),I&&(I.style.display="block")})(r,o,s??void 0):null!==s?((e,t)=>{x({body:D(e,t),title:`#${t}|placet experiri`,href:`${v}?tag=${t}`}),I&&(I.style.display="block")})(r,s):(e=>{x({body:E(e),title:"placet experiri",href:v}),I&&(I.style.display="block")})(r)}document.querySelectorAll(".bl_posts_dateago").forEach(e=>{const t=e.getAttribute("data-date");t&&(e.textContent=m(new Date(t)))}),async function(e=!0){const t=e?".":"..",n=`${t}/posts.json`,o=`${t}/about.json`,r=function(e){return[...e].reverse().map(e=>function(e){return{...e,title:null===e.title||""===e.title?void 0:e.title,plainText:e.text.replaceAll(/
    (.+?)<\/blockquote>/g,(e,t)=>`> ${t}`).replaceAll(/

    (.+?)<\/h2>/g,(e,t)=>`## ${t}`).replaceAll("——","──").replaceAll(/
    (.+?)<\/div>/g,"").replaceAll(/<("[^"]*"|'[^']*'|[^'">])*>/g,""),createdAt:y(e.createdAt),modifiedAt:y(e.modifiedAt)}}(e))}(await $(n));var a;e&&j(r),function(e){window.addEventListener("popstate",()=>e())}(a=()=>j(r)),function(e){const t=document.querySelector(".el_search_form"),n=document.querySelector(".el_search_input");t?.addEventListener("submit",t=>{t.preventDefault(),window.history.pushState(_(window.location.search,window.location.pathname,`#${n.value}`),"",`${window.location.search}#${n.value}`),e()})}(a),function(e){document.addEventListener("click",t=>{if(!(t.target instanceof Element))return;const n=t.target.closest('a[href^="#"], a[href^="/?"], a[href="/"], a[href^="/posts/"]');n&&(t.preventDefault(),window.history.pushState(void 0,"",n.href),e())})}(a),function(e){class t extends HTMLElement{constructor(){super();const t=this.getAttribute("id");if(!t)return;const n=e.find(e=>e.id===parseInt(t,10));n&&(this.innerHTML=(e=>{return`\n `;var t})(n))}}window.customElements.define("blog-card",t)}(r),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),f(new Date,n)}(new Date(e),t)||t<=0||(n?.addEventListener("click",()=>{localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await $(o))}(!1)})(); \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index ea104d0..c2d0185 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1 +1 @@ -(()=>{"use strict";var e={224(e,t){t.ur=t.Ks=t.xq=t.xS=void 0,t.xS=e=>(t,n)=>{if(void 0===n)return;const{maxLength:r=50,beforeLength:s=20}=null!=e?e:{},o=t.indexOf(n);if(-1===o)return;const i=o-s<=0?0:o-s,a=o+n.length,l=i+r;return{isBeforeEllipsed:0!==i,beforeText:t.substring(i,o),keyword:t.substring(o,a),afterText:t.substring(a,l),isAfterEllipsed:l(0,t.xS)(r)(e,n),t.Ks=e=>(n,r)=>{const s=(0,t.xq)(n,r,e);if(void 0===s)return;const{ellipsisToken:o="...",keywordModifier:i=e=>e}=null!=e?e:{};return(s.isBeforeEllipsed?o:"")+s.beforeText+i(s.keyword)+s.afterText+(s.isAfterEllipsed?o:"")},t.ur=(e,n,r)=>(0,t.Ks)(r)(e,n)}},t={};const n=e=>`#${e}`,r=(e,t)=>`\n
  • \n \n ${e===t?`${n(e)}`:n(e)}\n \n
  • `,s=(e,t)=>`\n
      \n ${e.map(e=>r(e,t)).join("")}\n
    \n`,o=864e5,i=30*o,a={seconds:1e3,minutes:6e4,hours:36e5,day:o,month:i,year:12*i},l=Object.keys(a);function c(e,t){return e/a[t]}function d(e){return{seconds:e.getSeconds(),minutes:e.getMinutes(),hours:e.getHours(),day:e.getDate(),month:e.getMonth()+1,year:e.getFullYear()}}function u(e){return String(e).padStart(2,"0")}function p(e){const{year:t,month:n,day:r}=d(e);return`${t}-${u(n)}-${u(r)}`}function h(e,t){return e.getTime()Math.abs(c(t,e)){e.href="/"+e.href.replace(y,"")}),document.title=e.title,t.innerText=e.suffix??"",e.description&&(n.content=e.description),e.href){document.querySelectorAll('link[rel="canonical"]').forEach(e=>e.remove());const t=document.createElement("link");t.rel="canonical",t.href=e.href,document.head.appendChild(t)}}const w=e=>{document.getElementById(e)?.scrollIntoView({behavior:"smooth"})};function x(e,t,n){const r=/\/posts\/(\d+)/.exec(t)?.[1],s=r?Number(r):void 0,o=new URLSearchParams(e),i=o.get("tag"),a=o.get("id"),l=a&&/^\d+$/.test(a)?Number(a):void 0;return{id:s??l,legacyId:l,tag:i??void 0,keyword:void 0===n||""===n?void 0:decodeURIComponent(n.replace("#",""))}}const L=e=>e.replace(/——(?![^(]*\))/g,'——'),k=(e,t)=>{return`\n
    \n
    \n
    \n \n
    \n
    \n ${e.title?`

    ${L(e.title)}

    `:""}\n ${(e=>e.replace(/——/g,"──").replace(/([!?]) /g,(e,t)=>`${t} `).replace(/

    ([「『(].+?)<\/p>/g,(e,t)=>`

    ${t}

    `))((n=e.text,n.replace(/(.+?)<\/a>/g,(e,t,n,r)=>(e=>{const{href:t,content:n,attributes:r=""}=e;return`${n}`})({href:t,attributes:n,content:r}))))}\n
    \n
    \n \n ${t?p(e.createdAt):m(e.createdAt)}\n \n ${s(e.tags)}\n
    \n
    \n
    `;var n};var S=function n(r){var s=t[r];if(void 0!==s)return s.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}(224);const T=e=>`${e}`,A=e=>{const{post:t,tag:n,keyword:r}=e,o=(0,S.ur)(t.plainText,r,{maxLength:200,beforeLength:48,keywordModifier:T});return`\n \n \n
    \n \n
    \n ${t.title?((e,t)=>`\n

    \n ${L(((e,t)=>t?e.replace(t,T(t)):e)(e,t))}\n

    `)(t.title,r):""}\n ${o?(e=>`\n
    \n

    \n ${e}\n

    \n
    `)(o):(i=t.plainText,`\n
    \n

    \n ${i.substring(0,300)}\n

    \n
    `)}\n
    \n
    \n \n ${m(t.createdAt)}\n \n ${s(t.tags,n)}\n
    \n `;var i},E=(e,t)=>`\n
    \n ${t?`

    ${t}

    `:""}\n
      \n ${e}\n
    \n
    `,D=e=>E(e.map((t,n)=>A({post:e[n]})).reverse().join("")),q=(e,t)=>E(e.map((n,r)=>n.tags.includes(t)?A({post:e[r],tag:t}):"").reverse().join(""),`#${t}`),I=(e,t,n)=>E(e.map((r,s)=>void 0===n||r.tags.includes(n)?A({post:e[s],tag:n,keyword:t}):"").reverse().join(""),`「${t}」`),j=document.querySelector(".el_search_input");function C(e){const{id:t,legacyId:n,tag:r,keyword:s}=x(window.location.search,window.location.pathname,window.location.hash),o=b(window.location.href)?e:function(e){return e.filter(e=>h(e.createdAt,new Date))}(e);if((e=>{window.scrollTo(0,0),j&&(j.style.display="block",e&&window.history.replaceState(void 0,"",`/posts/${e}`))})(n),void 0!==t){const e=o.find(e=>e.id===t);if(!e)return;(e=>{v((e=>({body:k(e,void 0),title:e.title?`${e.title}|placet experiri :: ${e.id}`:`placet experiri :: ${e.id}`,suffix:` :: ${e.id}`,description:`${e.plainText.substring(0,110)}…`,href:`${y}posts/${e.id}`}))(e)),j&&(j.style.display="none")})(e)}else void 0!==s?((e,t,n)=>{v({body:I(e,t,n),title:`「${t}」|placet experiri`})})(o,s,r):void 0!==r?((e,t)=>{v({body:q(e,t),title:`#${t}|placet experiri`,href:`${y}?tag=${t}`})})(o,r):(e=>{v({body:D(e),title:"placet experiri",href:y})})(o)}!async function(e=!0){const t=e?".":"..",n=`${t}/posts.json`,s=`${t}/about.json`,o=function(e){return[...e].reverse().map(e=>function(e){return{...e,title:null===e.title||""===e.title?void 0:e.title,plainText:e.text.replace(/
    (.+?)<\/blockquote>/g,(e,t)=>`> ${t}`).replace(/

    (.+?)<\/h2>/g,(e,t)=>`## ${t}`).replace(/——/g,"──").replace(/
    (.+?)<\/div>/g,"").replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,""),createdAt:_(e.createdAt),modifiedAt:_(e.modifiedAt)}}(e))}(await $(n));var i;e&&C(o),function(e){window.addEventListener("popstate",()=>e())}(i=()=>C(o)),function(e){const t=document.querySelector(".el_search_form"),n=document.querySelector(".el_search_input");t?.addEventListener("submit",t=>{t.preventDefault(),window.history.pushState(x(window.location.search,window.location.pathname,`#${n.value}`),"",`${window.location.search}#${n.value}`),e()})}(i),function(e){document.addEventListener("click",t=>{if(!(t.target instanceof Element))return;const n=t.target.closest('a[href^="#"], a[href^="/?"], a[href="/"], a[href^="/posts/"]');n&&(t.preventDefault(),window.history.pushState(void 0,"",n.href),e(),w(n.hash.replace("#","")))})}(i),function(e){class t extends HTMLElement{constructor(){super();const t=this.getAttribute("id");if(!t)return;const n=e.find(e=>e.id===parseInt(t,10));n&&(this.innerHTML=(e=>{return`\n `;var t})(n))}}window.customElements.define("blog-card",t)}(o),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),h(new Date,n)}(new Date(e),t)||t<=0||(n?.addEventListener("click",()=>{localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await $(s))}()})(); \ No newline at end of file +(()=>{"use strict";var e={224(e,t){t.ur=t.Ks=t.xq=t.xS=void 0,t.xS=e=>(t,n)=>{if(void 0===n)return;const{maxLength:s=50,beforeLength:r=20}=null!=e?e:{},o=t.indexOf(n);if(-1===o)return;const a=o-r<=0?0:o-r,i=o+n.length,l=a+s;return{isBeforeEllipsed:0!==a,beforeText:t.substring(a,o),keyword:t.substring(o,i),afterText:t.substring(i,l),isAfterEllipsed:l(0,t.xS)(s)(e,n),t.Ks=e=>(n,s)=>{const r=(0,t.xq)(n,s,e);if(void 0===r)return;const{ellipsisToken:o="...",keywordModifier:a=e=>e}=null!=e?e:{};return(r.isBeforeEllipsed?o:"")+r.beforeText+a(r.keyword)+r.afterText+(r.isAfterEllipsed?o:"")},t.ur=(e,n,s)=>(0,t.Ks)(s)(e,n)}},t={};const n=e=>`#${e}`,s=(e,t)=>`\n
  • \n \n ${e===t?`${n(e)}`:n(e)}\n \n
  • `,r=(e,t)=>`\n
      \n ${e.map(e=>s(e,t)).join("")}\n
    \n`,o=864e5,a=30*o,i={seconds:1e3,minutes:6e4,hours:36e5,day:o,month:a,year:12*a},l=Object.keys(i);function c(e,t){return e/i[t]}function d(e){return{seconds:e.getSeconds(),minutes:e.getMinutes(),hours:e.getHours(),day:e.getDate(),month:e.getMonth()+1,year:e.getFullYear()}}function u(e){return String(e).padStart(2,"0")}function p(e){const{year:t,month:n,day:s}=d(e);return`${t}-${u(n)}-${u(s)}`}function f(e,t){return e.getTime()Math.abs(c(t,e))e.replaceAll(/——(?![^(]*\))/g,'——'),v="https://kyonenya.github.io/";function x(e){const t=document.querySelector(".el_logo_suffix"),n=document.querySelector("meta[name=description]");if(function(e){document.getElementById("root").innerHTML=e;const t=document.getElementById(window.location.hash.replace("#",""));t?t.scrollIntoView({behavior:"smooth"}):window.scrollTo(0,0),b(window.location.href)&&document.querySelectorAll(`a[href^="${v}"]`).forEach(e=>{e.href="/"+e.href.replace(v,"")})}(e.body),document.title=e.title,t.innerText=e.suffix??"",e.description&&(n.content=e.description),e.href){document.querySelectorAll('link[rel="canonical"]').forEach(e=>e.remove());const t=document.createElement("link");t.rel="canonical",t.href=e.href,document.head.appendChild(t)}}const A=(e,t)=>{return`\n
    \n
    \n
    \n \n
    \n
    \n ${e.title?`

    ${w(e.title)}

    `:""}\n ${(e=>e.replaceAll("——","──").replaceAll(/([!?]) /g,(e,t)=>`${t} `).replaceAll(/

    ([「『(].+?)<\/p>/g,(e,t)=>`

    ${t}

    `))((n=e.text,n.replaceAll(/(.+?)<\/a>/g,(e,t,n,s)=>(e=>{const{href:t,content:n,attributes:s=""}=e;return`${n}`})({href:t,attributes:n,content:s}))))}\n
    \n
    \n \n ${t?p(e.createdAt):m(e.createdAt)}\n \n ${r(e.tags)}\n
    \n
    \n
    `;var n};var k=function n(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s](o,o.exports,n),o.exports}(224);const L=e=>`${e}`,S=e=>{const{post:t,tag:n,keyword:s}=e,o=(0,k.ur)(t.plainText,s,{maxLength:200,beforeLength:48,keywordModifier:L});return`\n \n \n
    \n \n
    \n ${t.title?((e,t)=>`\n

    \n ${w(((e,t)=>t?e.replace(t,L(t)):e)(e,t))}\n

    `)(t.title,s):""}\n ${o?(e=>`\n
    \n

    \n ${e}\n

    \n
    `)(o):(a=t.plainText,`\n
    \n

    \n ${a.substring(0,300)}\n

    \n
    `)}\n
    \n
    \n \n ${m(t.createdAt)}\n \n ${r(t.tags,n)}\n
    \n `;var a},T=(e,t)=>`\n
    \n ${t?`

    ${t}

    `:""}\n
      \n ${e}\n
    \n
    `,E=e=>T(e.map((t,n)=>S({post:e[n]})).reverse().join("")),D=(e,t)=>T(e.map((n,s)=>n.tags.includes(t)?S({post:e[s],tag:t}):"").reverse().join(""),`#${t}`),q=(e,t,n)=>T(e.map((s,r)=>void 0===n||s.tags.includes(n)?S({post:e[r],tag:n,keyword:t}):"").reverse().join(""),`「${t}」`),I=document.querySelector(".el_search_input");function j(e){const{id:t,legacyId:n,tag:s,keyword:r}=_(window.location.search,window.location.pathname,window.location.hash),o=b(window.location.href)?e:function(e){return e.filter(e=>f(e.createdAt,new Date))}(e);if((e=>{null!==e&&window.history.replaceState(void 0,"",`/posts/${e}`)})(n),null!==t){const e=o.find(e=>e.id===t);if(!e)return;(e=>{x((e=>({body:A(e,void 0),title:e.title?`${e.title}|placet experiri :: ${e.id}`:`placet experiri :: ${e.id}`,suffix:` :: ${e.id}`,description:`${e.plainText.substring(0,110)}…`,href:`${v}posts/${e.id}`}))(e)),I&&(I.style.display="none")})(e)}else null!==r?((e,t,n)=>{x({body:q(e,t,n),title:`「${t}」|placet experiri`}),I&&(I.style.display="block")})(o,r,s??void 0):null!==s?((e,t)=>{x({body:D(e,t),title:`#${t}|placet experiri`,href:`${v}?tag=${t}`}),I&&(I.style.display="block")})(o,s):(e=>{x({body:E(e),title:"placet experiri",href:v}),I&&(I.style.display="block")})(o)}!async function(e=!0){const t=e?".":"..",n=`${t}/posts.json`,r=`${t}/about.json`,o=function(e){return[...e].reverse().map(e=>function(e){return{...e,title:null===e.title||""===e.title?void 0:e.title,plainText:e.text.replaceAll(/
    (.+?)<\/blockquote>/g,(e,t)=>`> ${t}`).replaceAll(/

    (.+?)<\/h2>/g,(e,t)=>`## ${t}`).replaceAll("——","──").replaceAll(/
    (.+?)<\/div>/g,"").replaceAll(/<("[^"]*"|'[^']*'|[^'">])*>/g,""),createdAt:y(e.createdAt),modifiedAt:y(e.modifiedAt)}}(e))}(await $(n));var a;e&&j(o),function(e){window.addEventListener("popstate",()=>e())}(a=()=>j(o)),function(e){const t=document.querySelector(".el_search_form"),n=document.querySelector(".el_search_input");t?.addEventListener("submit",t=>{t.preventDefault(),window.history.pushState(_(window.location.search,window.location.pathname,`#${n.value}`),"",`${window.location.search}#${n.value}`),e()})}(a),function(e){document.addEventListener("click",t=>{if(!(t.target instanceof Element))return;const n=t.target.closest('a[href^="#"], a[href^="/?"], a[href="/"], a[href^="/posts/"]');n&&(t.preventDefault(),window.history.pushState(void 0,"",n.href),e())})}(a),function(e){class t extends HTMLElement{constructor(){super();const t=this.getAttribute("id");if(!t)return;const n=e.find(e=>e.id===parseInt(t,10));n&&(this.innerHTML=(e=>{return`\n `;var t})(n))}}window.customElements.define("blog-card",t)}(o),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),f(new Date,n)}(new Date(e),t)||t<=0||(n?.addEventListener("click",()=>{localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await $(r))}()})(); \ No newline at end of file diff --git a/dist/works.js b/dist/works.js index 8534f4e..0f16265 100644 --- a/dist/works.js +++ b/dist/works.js @@ -1 +1 @@ -(()=>{"use strict";async function e(e){const t=await fetch(e);if(!t.ok)throw new Error(t.statusText);return await t.json()}const t="https://kyonenya.github.io/";const n=e=>{document.getElementById(e)?.scrollIntoView({behavior:"smooth"})},o=864e5,a=30*o,r={seconds:1e3,minutes:6e4,hours:36e5,day:o,month:a,year:12*a};Object.keys(r),new Intl.RelativeTimeFormat("ja-JP",{style:"narrow"});const s=["書籍","学位論文","論文","発表","翻訳"];const c=e=>function(e){return e.replace(/\[(.+?)\]\((.+?)\)/g,(e,t,n)=>(e=>{const{href:t,content:n,attributes:o=""}=e;return`${n}`})({content:t,href:n}))}((e=>e.replace(/——(?![^(]*\))/g,'——'))(e)),i=e=>`${c(e)}`,l=(e,t,n)=>{const o=e.id.toString()===n,a=o?`${t}.`:`${t}.`,r=function(e){const t=e.issued?.["date-parts"]?.[0]??e["event-date"]?.["date-parts"]?.[0];if(t)return new Date\n ${a}\n ${(o?i:c)(e._bibliographyText)}${r}\n \n `};!async function(){const o=await e("./works.json"),a=decodeURIComponent(window.location.hash.replace("#",""));var r,c;r=((e,t)=>{const n=(e=>Object.fromEntries(s.map(t=>[t,e.filter(e=>function(e){switch(e.type){case"book":return"書籍";case"article-journal":return e.translator?"翻訳":"論文";case"paper-conference":return"発表";case"thesis":return"学位論文";default:return}}(e)===t)])))(e);return`\n
    \n
    \n

    業績一覧

    \n ${s.map(e=>((e,t,n)=>e&&0!==e.length?`\n

    ${t}

    \n
      \n ${e.map((e,t)=>l(e,t+1,n)).join("")}\n
    `:"")(n[e],e,t)).join("")}\n
    \n
    `})(o,a),document.getElementById("root").innerHTML=r,n(window.location.hash.replace("#","")),c=window.location.href,/localhost:\d+/.test(c)&&document.querySelectorAll(`a[href^="${t}"]`).forEach(e=>{e.href="/"+e.href.replace(t,"")}),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),function(e,t){return e.getTime(){localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await e("./about.json"))}()})(); \ No newline at end of file +(()=>{"use strict";async function e(e){const t=await fetch(e);if(!t.ok)throw new Error(t.statusText);return await t.json()}const t=864e5,n=30*t,o={seconds:1e3,minutes:6e4,hours:36e5,day:t,month:n,year:12*n};Object.keys(o),new Intl.RelativeTimeFormat("ja-JP",{style:"narrow"});const a="https://kyonenya.github.io/";const r=["書籍","学位論文","論文","発表","翻訳"];const s=e=>function(e){return e.replaceAll(/\[(.+?)\]\((.+?)\)/g,(e,t,n)=>(e=>{const{href:t,content:n,attributes:o=""}=e;return`${n}`})({content:t,href:n}))}((e=>e.replaceAll(/——(?![^(]*\))/g,'——'))(e)),c=e=>`${s(e)}`,i=(e,t,n)=>{const o=e.id.toString()===n,a=o?`${t}.`:`${t}.`,r=function(e){const t=e.issued?.["date-parts"]?.[0]??e["event-date"]?.["date-parts"]?.[0];if(t)return new Date\n ${a}\n ${(o?c:s)(e._bibliographyText)}${r}\n \n `};!async function(){!function(e){document.getElementById("root").innerHTML=e;const t=document.getElementById(window.location.hash.replace("#",""));var n;t?t.scrollIntoView({behavior:"smooth"}):window.scrollTo(0,0),n=window.location.href,/localhost:\d+/.test(n)&&document.querySelectorAll(`a[href^="${a}"]`).forEach(e=>{e.href="/"+e.href.replace(a,"")})}(((e,t)=>{const n=(e=>Object.fromEntries(r.map(t=>[t,e.filter(e=>function(e){switch(e.type){case"book":return"書籍";case"article-journal":return e.translator?"翻訳":"論文";case"paper-conference":return"発表";case"thesis":return"学位論文";default:return}}(e)===t)])))(e);return`\n
    \n
    \n

    業績一覧

    \n ${r.map(e=>((e,t,n)=>e&&0!==e.length?`\n

    ${t}

    \n
      \n ${e.map((e,t)=>i(e,t+1,n)).join("")}\n
    `:"")(n[e],e,t)).join("")}\n
    \n
    `})(await e("./works.json"),decodeURIComponent(window.location.hash.replace("#","")))),function({updatedAt:e,newDays:t}){const n=document.getElementById("about");!function(e,t){const n=new Date(e);return n.setDate(e.getDate()+t),function(e,t){return e.getTime(){localStorage.setItem(e,"true")}),"true"!==localStorage.getItem(e)&&n?.classList.add("el_badge"))}(await e("./about.json"))}()})(); \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index a31a0a2..76c5b05 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,6 @@ import js from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier'; import { defineConfig } from 'eslint/config'; import importPlugin from 'eslint-plugin-import'; -import { fileURLToPath } from 'node:url'; import tseslint from 'typescript-eslint'; export default defineConfig( @@ -28,6 +27,13 @@ export default defineConfig( alphabetize: { order: 'asc' }, }, ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: false, // allow `import('foo').Bar` + }, + ], }, }, diff --git a/generateBibliography.js b/generateBibliography.js deleted file mode 100644 index 891661d..0000000 --- a/generateBibliography.js +++ /dev/null @@ -1,84 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const CSL = require('citeproc'); -const prettier = require('prettier'); - -const worksPath = path.resolve('works.json'); -const stylePath = path.resolve('assets', 'citeproc', 'sist02modified.csl'); -const localePath = path.resolve('assets', 'citeproc', 'locales-ja-JP.xml'); - -/** - * @param {object} obj - * @return {object} - */ -function removeNullProperties(obj) { - return Object.entries(obj) - .filter(([_k, v]) => v !== null) - .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}); -} - -/** - * @param {import('csl-json').Data[]} data - * @param {string} style - * @param {string} locale - * @return {string[]} - */ -function citeproc(data, style, locale) { - const items = data.map((item) => ({ - ...removeNullProperties(item), - id: item.id.toString(), - })); - const sys = { - retrieveLocale: (_lang) => locale, - retrieveItem: (id) => items.find((item) => id === item.id), - }; - const citeproc = new CSL.Engine(sys, style); - citeproc.setOutputFormat('text'); - - citeproc.updateItems(items.map((item) => item.id)); - const bib = citeproc.makeBibliography(); - if (bib === false) return []; - - return bib[1].map((text) => text.replace(/\n$/, '')); -} - -/** - * @param {import('csl-json').Data[]} items - * @return {import('./src/works/citation').Citation[]} - */ -function AppendBibliopraphy(items) { - const bibTexts = citeproc( - items, - fs.readFileSync(stylePath, 'utf-8'), - fs.readFileSync(localePath, 'utf-8'), - ); - return items.map((item, i) => ({ - ...item, - _bibliographyText: bibTexts[i], - })); -} - -/** - * @return {Promise} - */ -async function generateBibliography() { - const works = JSON.parse(fs.readFileSync(worksPath, 'utf8')); - const newWorks = AppendBibliopraphy(works); - fs.writeFileSync( - worksPath, - await prettier.format(JSON.stringify(newWorks), { - semi: false, - parser: 'json', - }), - ); - console.log('bibliography generated.'); -} - -module.exports = generateBibliography; - -if (require.main === module) { - generateBibliography().catch((e) => { - console.error(e); - process.exitCode = 1; - }); -} diff --git a/generatePosts.js b/generatePosts.js deleted file mode 100644 index b7cb1bc..0000000 --- a/generatePosts.js +++ /dev/null @@ -1,100 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const prettier = require('prettier'); -const MarkdownIt = require('markdown-it'); -const MarkdownItFootnote = require('markdown-it-footnote'); -const matter = require('gray-matter'); - -const md = new MarkdownIt(); -md.use(MarkdownItFootnote); - -const jsonPath = path.resolve(__dirname, 'posts.json'); -const mdPath = path.resolve(__dirname, 'markdown'); - -/** - * @param dir string - * @return string[] - */ -function listFiles(dir) { - return fs - .readdirSync(dir, { withFileTypes: true }) - .flatMap((dirent) => { - if (/^\..*/.test(dirent.name)) return; // exclude '.icloud' file - return dirent.isFile() - ? [`${dir}/${dirent.name}`] - : listFiles(`${dir}/${dirent.name}`); - }) - .filter((v) => v !== undefined); -} - -/** - * @param paths string[] - * @return {import('./src/post').JSONPost[]} - */ -function readPostsMarkdown(paths) { - return paths - .map((path) => fs.readFileSync(path, 'utf-8')) - .map((string) => matter(string)) - .map((matter) => ({ - ...matter.data, - text: md - .render(matter.content) - .replace(/\n/g, '') - .replace(/>/g, '>') - .replace(/</g, '<') - // 漢字《ふりがな》 - .replace(/|(.+?)《(.+?)》/g, '$1$2') - .replace(/\{(.+?)\|(.+?)\}/g, '$1$2') - .replace(/([一-龠]+)《(.+?)》/g, '$1$2'), - })); -} - -/** - * @param posts {import('./src/post').JSONPost[]} - * @return {import('./src/post').JSONPost[]} - */ -function uniquePosts(posts) { - const uniquePosts = Array.from( - new Map(posts.map((post) => [post.id, post])).values(), - ); - return uniquePosts.sort(function (a, b) { - if (a.id < b.id) return 1; - if (a.id > b.id) return -1; - return 0; - }); -} - -/** - * @param posts {import('./src/post').JSONPost[]} - * @param mdPosts {import('./src/post').JSONPost[]} - * @return {Promise} - */ -async function writePostsJson(posts, mdPosts) { - fs.writeFileSync( - jsonPath, - await prettier.format(JSON.stringify(uniquePosts([...mdPosts, ...posts])), { - semi: false, - parser: 'json', - }), - ); -} - -/** - * @return {Promise} - */ -async function generatePosts() { - await writePostsJson( - readPostsMarkdown(listFiles(mdPath)), - JSON.parse(fs.readFileSync(jsonPath, 'utf-8')), - ); - console.log('posts genarated.'); -} - -module.exports = generatePosts; - -if (require.main === module) { - generatePosts().catch((e) => { - console.error(e); - process.exitCode = 1; - }); -} diff --git a/generateSitemap.js b/generateSitemap.js deleted file mode 100644 index eab3ea2..0000000 --- a/generateSitemap.js +++ /dev/null @@ -1,66 +0,0 @@ -const { writeFileSync } = require('fs'); -const { SitemapStream, streamToPromise } = require('sitemap'); -/** @type {(xml: string, options?: object) => string} */ -const format = /** @type {any} */ (require('xml-formatter')); -const { updatedAt } = require('./about.json'); - -const sitemap = new SitemapStream({ hostname: 'https://kyonenya.github.io/' }); - -/** - * @param {import('./src/post').JSONPost[]} posts - * @return {string} - */ -function getLatestModifiedAt(posts) { - return posts - .map((post) => post.modifiedAt) - .sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0]; -} - -/** - * @param {import('./src/post').JSONPost[]} posts - * @return { {tag: string, modifiedAt: string}[] } - */ -function tagHistory(posts) { - const tags = [...new Set(posts.map((post) => post.tags).flat())]; // uniq - return tags.map((tag) => { - return { - tag, - modifiedAt: getLatestModifiedAt( - posts.filter((post) => post.tags.includes(tag)), - ), - }; - }); -} - -/** - * @param {import('./src/post').JSONPost[]} posts - * @return {void} - */ -function generateSitemap(posts) { - sitemap.write({ url: '', lastmod: getLatestModifiedAt(posts) }); - sitemap.write({ url: 'works', lastmod: updatedAt }); - sitemap.write({ url: 'about', lastmod: updatedAt }); - posts.forEach((post) => - sitemap.write({ url: `/posts/${post.id}`, lastmod: post.modifiedAt }), - ); - tagHistory(posts).forEach(({ tag, modifiedAt }) => - sitemap.write({ url: `?tag=${tag}`, lastmod: modifiedAt }), - ); - sitemap.end(); - - streamToPromise(sitemap) - .then((sm) => - writeFileSync( - './sitemap.xml', - format(sm.toString(), { indentation: ' ', collapseContent: true }), - ), - ) - .then(() => console.log('sitemap generated.')); -} - -module.exports = generateSitemap; - -if (require.main === module) { - const posts = require('./posts.json'); - generateSitemap(posts); -} diff --git a/index.js b/index.mjs similarity index 52% rename from index.js rename to index.mjs index 6b10266..37de492 100644 --- a/index.js +++ b/index.mjs @@ -1,18 +1,20 @@ -const express = require('express'); -const fs = require('fs'); -const path = require('path'); -const webpack = require('webpack'); -const webpackDevMiddleware = require('webpack-dev-middleware'); -const config = require('./webpack.dev.config.js'); -const generatePosts = require('./generatePosts'); -const generateSitemap = require('./generateSitemap'); -const generateBibliography = require('./generateBibliography'); -const { createJiti } = require('jiti'); -const jiti = createJiti(__filename); -const { generateStaticHTML } = jiti('./src/ssg.ts'); +import express from 'express'; +import { createJiti } from 'jiti'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import webpack from 'webpack'; +import webpackDevMiddleware from 'webpack-dev-middleware'; +import config from './webpack.dev.config.mjs'; -const rootDir = __dirname; -const port = process.env['WEB_APP_PORT'] ?? 3100; +const filename = fileURLToPath(import.meta.url); +const rootDir = path.dirname(filename); +const jiti = createJiti(import.meta.url); +const { generatePostsJson } = jiti('./src/scripts/postsJson.ts'); +const { generateSitemap } = jiti('./src/scripts/sitemap.ts'); +const { generateWorksJson } = jiti('./src/scripts/worksJson.ts'); +const { generateStaticHtml } = jiti('./src/ssg.ts'); + +const port = 3100; express() .use(webpackDevMiddleware(webpack(config))) @@ -38,11 +40,10 @@ express() ); (async () => { - await generatePosts(); - await generateBibliography(); - const posts = JSON.parse( - fs.readFileSync(path.resolve(__dirname, 'posts.json'), 'utf8'), - ); - generateSitemap(posts); - generateStaticHTML(); + const posts = await generatePostsJson(); + await Promise.all([ + generateWorksJson(), + generateSitemap(posts), + generateStaticHtml(posts), + ]); })().catch((e) => console.error(e)); diff --git a/package-lock.json b/package-lock.json index 9982cd5..4b472b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/markdown-it": "^14.1.2", + "@types/markdown-it-footnote": "^3.0.4", "@types/node": "18.19.130", "citeproc": "^2.4.63", "csl-json": "^0.1.0", @@ -567,6 +569,37 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/markdown-it-footnote": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/markdown-it-footnote/-/markdown-it-footnote-3.0.4.tgz", + "integrity": "sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==", + "dev": true, + "dependencies": { + "@types/markdown-it": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", @@ -7889,6 +7922,37 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "requires": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "@types/markdown-it-footnote": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/markdown-it-footnote/-/markdown-it-footnote-3.0.4.tgz", + "integrity": "sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==", + "dev": true, + "requires": { + "@types/markdown-it": "*" + } + }, + "@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", diff --git a/package.json b/package.json index 86c2ba8..f42e15b 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,24 @@ "name": "kyonenya.github.io", "version": "2.5.0", "scripts": { - "dev": "node index.js", + "dev": "node index.mjs", "tsc": "tsc -w --noEmit", - "tsc-js": "tsc -w --noEmit --resolveJsonModule --p tsconfig.checkjs.json", + "tsc:once": "tsc --noEmit", "build": "node ./node_modules/webpack/bin/webpack.js", "build-css": "postcss src/css/index.css -o dist/css/bundle.css --verbose", "fmt": "prettier --write **/*.{ts,js,mjs,json,css}", - "lint": "eslint --fix src/**/*.ts", - "lint-css": "node ./node_modules/stylelint/bin/stylelint.mjs src/**/*.css", - "lint-css:fix": "node ./node_modules/stylelint/bin/stylelint.mjs src/**/*.css --fix" + "lint": "eslint src/*.ts src/**/*.ts --debug", + "lint:fix": "eslint src/*.ts src/**/*.ts --fix --debug", + "lint-css": "node ./node_modules/stylelint/bin/stylelint.mjs src/**/*.css --formatter verbose", + "lint-css:fix": "node ./node_modules/stylelint/bin/stylelint.mjs src/**/*.css --fix --formatter verbose" }, "dependencies": { "search-summary": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/markdown-it": "^14.1.2", + "@types/markdown-it-footnote": "^3.0.4", "@types/node": "18.19.130", "citeproc": "^2.4.63", "csl-json": "^0.1.0", diff --git a/src/Article.ts b/src/Article.ts index 0015a35..b7d8b60 100644 --- a/src/Article.ts +++ b/src/Article.ts @@ -2,8 +2,8 @@ import { TagList } from './TagList'; import { toExternalLink } from './lib/ExternalLink'; import { MarkupText, kerningDoubleDash } from './lib/MarkupText'; import { formatYMDHm, formatYMD, fromNow } from './lib/dateUtils'; -import { baseUrl, Page } from './lib/render'; -import { Post } from './post'; +import type { Post } from './post'; +import { baseUrl, type Page } from './render'; const Article = (post: Post, ssg?: boolean): string => `
    diff --git a/src/BlogCard.ts b/src/BlogCard.ts index 2f2f39b..e235d06 100644 --- a/src/BlogCard.ts +++ b/src/BlogCard.ts @@ -1,6 +1,6 @@ import { TagListItem } from './TagList'; import { formatYMD } from './lib/dateUtils'; -import { Post } from './post'; +import type { Post } from './post'; const Summary = (plainText: string) => `

    diff --git a/src/PostList.ts b/src/PostList.ts index 7b025c9..21a8e7e 100644 --- a/src/PostList.ts +++ b/src/PostList.ts @@ -1,5 +1,5 @@ import { PostListItem } from './PostListItem'; -import { Post } from './post'; +import type { Post } from './post'; const Container = (listItems: string, archiveHeaderText?: string) => `

    diff --git a/src/PostListItem.ts b/src/PostListItem.ts index ee595b0..eafbe53 100644 --- a/src/PostListItem.ts +++ b/src/PostListItem.ts @@ -2,7 +2,7 @@ import { generateSummary } from 'search-summary'; import { TagList } from './TagList'; import { kerningDoubleDash } from './lib/MarkupText'; import { formatYMD, fromNow } from './lib/dateUtils'; -import { Post } from './post'; +import type { Post } from './post'; const Keyword = (keyword: string) => `${keyword}`; diff --git a/src/app.ts b/src/app.ts index f349e9b..296a042 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { defineBlogCard } from './BlogCard'; import { fetcher } from './lib/utils'; -import { notifyUpdate, Update } from './notify'; -import { jsonToPosts, JSONPost } from './post'; +import { notifyUpdate, type Update } from './notify'; +import { jsonToPosts, type JSONPost } from './post'; import { registerRerouter } from './reroute'; import { route } from './route'; diff --git a/src/lib/ExternalLink.ts b/src/lib/ExternalLink.ts index 4dbd8bb..7dd06d1 100644 --- a/src/lib/ExternalLink.ts +++ b/src/lib/ExternalLink.ts @@ -8,7 +8,7 @@ const ExternalLink = (props: { }; export function toExternalLink(html: string): string { - return html.replace( + return html.replaceAll( /(.+?)<\/a>/g, // href start with '?' or '/' (_, href: string, attributes: string, content: string) => ExternalLink({ href, attributes, content }), @@ -16,7 +16,7 @@ export function toExternalLink(html: string): string { } export function parseMarkdownLink(text: string): string { - return text.replace( + return text.replaceAll( /\[(.+?)\]\((.+?)\)/g, (_, content: string, href: string) => ExternalLink({ content, href }), ); diff --git a/src/lib/MarkupText.ts b/src/lib/MarkupText.ts index 5f5c4a3..de3f46a 100644 --- a/src/lib/MarkupText.ts +++ b/src/lib/MarkupText.ts @@ -2,14 +2,14 @@ export const MarkupText = (html: string): string => html // double dash -> double ruled line - .replace(/——/g, '──') + .replaceAll('——', '──') // full-width space -> half-width space after (!|?) - .replace(/([!?]) /g, (_, token: string) => `${token} `) + .replaceAll(/([!?]) /g, (_, token: string) => `${token} `) // unset paragraph indent start with brackets - .replace( + .replaceAll( /

    ([「『(].+?)<\/p>/g, (_, content: string) => `

    ${content}

    `, ); export const kerningDoubleDash = (text: string): string => - text.replace(/——(?![^(]*\))/g, '——'); // kerning except link href; + text.replaceAll(/——(?![^(]*\))/g, '——'); // kerning except link href; diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts index d2b86c5..19fd6f5 100644 --- a/src/lib/dateUtils.ts +++ b/src/lib/dateUtils.ts @@ -1,4 +1,4 @@ -import { toUnitTime, units, getDateParts, DateParts } from './dateConst'; +import { toUnitTime, units, getDateParts, type DateParts } from './dateConst'; function pad2(num: number): string { return String(num).padStart(2, '0'); diff --git a/src/post.ts b/src/post.ts index 0b61ea8..249d581 100644 --- a/src/post.ts +++ b/src/post.ts @@ -31,14 +31,14 @@ export function jsonToPost(post: JSONPost): Post { ...post, title: post.title === null || post.title === '' ? undefined : post.title, plainText: post.text - .replace( + .replaceAll( /
    (.+?)<\/blockquote>/g, (_, text: string) => `> ${text}`, ) - .replace(/

    (.+?)<\/h2>/g, (_, text: string) => `## ${text}`) - .replace(/——/g, '──') - .replace(/
    (.+?)<\/div>/g, '') - .replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, ''), + .replaceAll(/

    (.+?)<\/h2>/g, (_, text: string) => `## ${text}`) + .replaceAll('——', '──') + .replaceAll(/
    (.+?)<\/div>/g, '') + .replaceAll(/<("[^"]*"|'[^']*'|[^'">])*>/g, ''), createdAt: parseDate(post.createdAt), modifiedAt: parseDate(post.modifiedAt), }; diff --git a/src/lib/render.ts b/src/render.ts similarity index 84% rename from src/lib/render.ts rename to src/render.ts index bab4119..2490b7c 100644 --- a/src/lib/render.ts +++ b/src/render.ts @@ -1,4 +1,4 @@ -import { isDevelopment } from './utils'; +import { isDevelopment } from './lib/utils'; export type Page = { body: string; @@ -13,7 +13,13 @@ export const baseUrl = 'https://kyonenya.github.io/'; export function renderRoot(html: string): void { const rootElement = document.getElementById('root'); rootElement.innerHTML = html; - scrollToId(window.location.hash.replace('#', '')); + + const target = document.getElementById(window.location.hash.replace('#', '')); + if (target) { + target.scrollIntoView({ behavior: 'smooth' }); + } else { + window.scrollTo(0, 0); + } if (isDevelopment(window.location.href)) { // overwrite to internal link @@ -49,9 +55,3 @@ export function renderPage(page: Page): void { document.head.appendChild(link); } } - -export const scrollToId = (id: string): void => { - document.getElementById(id)?.scrollIntoView({ - behavior: 'smooth', - }); -}; diff --git a/src/reroute.ts b/src/reroute.ts index 3bf0e3a..c021ab7 100644 --- a/src/reroute.ts +++ b/src/reroute.ts @@ -1,4 +1,3 @@ -import { scrollToId } from './lib/render'; import { toState } from './state'; function watchPopState(reroute: () => void): void { @@ -36,7 +35,6 @@ function watchInternalLinkClicks(reroute: () => void): void { e.preventDefault(); window.history.pushState(undefined, '', a.href); reroute(); - scrollToId(a.hash.replace('#', '')); } }); } diff --git a/src/route.ts b/src/route.ts index 0b94168..b369451 100644 --- a/src/route.ts +++ b/src/route.ts @@ -1,8 +1,8 @@ import { articlePage } from './Article'; import { PostList, TaggedPostList, SearchedPostList } from './PostList'; -import { renderPage, baseUrl } from './lib/render'; import { isDevelopment } from './lib/utils'; -import { Post, excludeReserved } from './post'; +import { excludeReserved, type Post } from './post'; +import { renderPage, baseUrl } from './render'; import { toState } from './state'; const searchInputElement = @@ -11,31 +11,34 @@ const searchInputElement = const routeMap = { article: (post: Post): void => { renderPage(articlePage(post)); - if (!searchInputElement) return; - searchInputElement.style.display = 'none'; // disable search form + // disable search form + if (searchInputElement) searchInputElement.style.display = 'none'; }, - postList: (posts: Post[]): void => + postList: (posts: Post[]): void => { renderPage({ body: PostList(posts), title: 'placet experiri', href: baseUrl, - }), - taggedPostList: (posts: Post[], tag: string): void => + }); + if (searchInputElement) searchInputElement.style.display = 'block'; + }, + taggedPostList: (posts: Post[], tag: string): void => { renderPage({ body: TaggedPostList(posts, tag), title: `#${tag}|placet experiri`, href: `${baseUrl}?tag=${tag}`, - }), - searchedPostList: (posts: Post[], keyword: string, tag?: string): void => + }); + if (searchInputElement) searchInputElement.style.display = 'block'; + }, + searchedPostList: (posts: Post[], keyword: string, tag?: string): void => { renderPage({ body: SearchedPostList(posts, keyword, tag), title: `「${keyword}」|placet experiri`, - }), - beforeEach: (legacyId: number | undefined): void => { - window.scrollTo(0, 0); - if (!searchInputElement) return; - searchInputElement.style.display = 'block'; - if (legacyId) { + }); + if (searchInputElement) searchInputElement.style.display = 'block'; + }, + beforeEach: (legacyId: number | null): void => { + if (legacyId !== null) { // for backward compatibility window.history.replaceState(undefined, '', `/posts/${legacyId}`); } @@ -55,13 +58,13 @@ export function route(rawPosts: Post[]): void { routeMap.beforeEach(legacyId); - if (id !== undefined) { + if (id !== null) { const post = posts.find((post) => post.id === id); if (!post) return; // TODO: 404 routeMap.article(post); - } else if (keyword !== undefined) { - routeMap.searchedPostList(posts, keyword, tag); - } else if (tag !== undefined) { + } else if (keyword !== null) { + routeMap.searchedPostList(posts, keyword, tag ?? undefined); + } else if (tag !== null) { routeMap.taggedPostList(posts, tag); } else { routeMap.postList(posts); diff --git a/src/scripts/citeproc.d.ts b/src/scripts/citeproc.d.ts new file mode 100644 index 0000000..02c9c70 --- /dev/null +++ b/src/scripts/citeproc.d.ts @@ -0,0 +1,37 @@ +declare module 'citeproc' { + export type Item = Partial & { + id: string; + }; + + export type Bibliography = [ + params: Record, + entries: string[], + ]; + + export type Sys = { + retrieveLocale: (lang: string) => string; + retrieveItem: (id: string) => Item; + variableWrapper?: (...args: unknown[]) => unknown; + stringCompare?: (a: string, b: string) => number; + }; + + export class Engine { + constructor(sys: Sys, style: string, lang?: string, forceLang?: boolean); + setOutputFormat: (mode: string) => void; + updateItems: ( + idList: string[], + nosort?: boolean, + rerunAmbigs?: boolean, + implicitUpdate?: boolean, + ) => void; + makeBibliography: ( + bibsection?: Record, + ) => false | Bibliography; + } + + const Citeproc: { + Engine: typeof Engine; + }; + + export default Citeproc; +} diff --git a/src/scripts/postsJson.ts b/src/scripts/postsJson.ts new file mode 100644 index 0000000..4bd8ef7 --- /dev/null +++ b/src/scripts/postsJson.ts @@ -0,0 +1,82 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import matter from 'gray-matter'; +import MarkdownIt from 'markdown-it'; +import markdownItFootnote from 'markdown-it-footnote'; +import { format } from 'prettier'; +import type { JSONPost } from '../post'; + +const md = new MarkdownIt(); +md.use(markdownItFootnote); + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const rootDir = path.resolve(dirname, '../..'); +const jsonPath = path.resolve(rootDir, 'posts.json'); +const mdPath = path.resolve(rootDir, 'markdown'); + +async function listFiles(dir: string): Promise { + const dirents = await readdir(dir, { withFileTypes: true }); + const paths = await Promise.all( + dirents.map(async (dirent) => { + if (/^\..*/.test(dirent.name)) return []; // exclude '.icloud' file + const filePath = path.resolve(dir, dirent.name); + if (dirent.isFile()) return [filePath]; + return await listFiles(filePath); + }), + ); + return paths.flat(); +} + +function replacePostHtml(html: string): string { + return ( + html + .replaceAll('\n', '') + .replaceAll('>', '>') + .replaceAll('<', '<') + // 漢字《ふりがな》 + .replaceAll(/|(.+?)《(.+?)》/g, '$1$2') + .replaceAll(/\{(.+?)\|(.+?)\}/g, '$1$2') + .replaceAll(/([一-龠]+)《(.+?)》/g, '$1$2') + ); +} + +async function readPostsMarkdown(paths: string[]): Promise { + return await Promise.all( + paths.map(async (filePath) => { + const { data, content } = matter(await readFile(filePath, 'utf-8')); + return { + ...data, + text: replacePostHtml(md.render(content)), + } as JSONPost; + }), + ); +} + +function uniquePosts(posts: JSONPost[]): JSONPost[] { + return Array.from( + new Map(posts.map((post) => [post.id, post])).values(), + ).sort((a, b) => b.id - a.id); +} + +export async function generatePostsJson(): Promise { + const [mdPaths, postsJson] = await Promise.all([ + listFiles(mdPath), + readFile(jsonPath, 'utf-8'), + ]); + const jsonPosts = JSON.parse(postsJson) as JSONPost[]; + const mdPosts = await readPostsMarkdown(mdPaths); + const newPosts = uniquePosts([...jsonPosts, ...mdPosts]); // mdPosts > jsonPosts + await writeFile( + jsonPath, + await format(JSON.stringify(newPosts), { parser: 'json' }), + ); + console.log('posts generated.'); + + return newPosts; +} + +if (path.resolve(process.argv[1] ?? '') === filename) { + void generatePostsJson(); +} diff --git a/src/scripts/sitemap.ts b/src/scripts/sitemap.ts new file mode 100644 index 0000000..c45b338 --- /dev/null +++ b/src/scripts/sitemap.ts @@ -0,0 +1,66 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SitemapStream, streamToPromise } from 'sitemap'; +import format from 'xml-formatter'; +import type { Update } from '../notify'; +import type { JSONPost } from '../post'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const rootDir = path.resolve(dirname, '../..'); +const sitemapPath = path.resolve(rootDir, 'sitemap.xml'); + +function getLatestModifiedAt(posts: JSONPost[]): string | null { + const latest = Math.max( + ...posts.map((post) => new Date(post.modifiedAt).getTime()), + ); + return ( + posts.find((post) => new Date(post.modifiedAt).getTime() === latest) + ?.modifiedAt ?? null + ); +} + +function tagHistory(posts: JSONPost[]): { tag: string; modifiedAt: string }[] { + const tags = [...new Set(posts.flatMap((post) => post.tags))]; + return tags.map((tag) => ({ + tag, + modifiedAt: + getLatestModifiedAt(posts.filter((post) => post.tags.includes(tag))) ?? + '', + })); +} + +export async function generateSitemap(posts: JSONPost[]): Promise { + const { updatedAt } = JSON.parse( + await readFile(path.resolve(rootDir, 'about.json'), 'utf8'), + ) as Update; + const sitemap = new SitemapStream({ + hostname: 'https://kyonenya.github.io/', + }); + + sitemap.write({ url: '', lastmod: getLatestModifiedAt(posts) ?? '' }); + sitemap.write({ url: 'works', lastmod: updatedAt }); + sitemap.write({ url: 'about', lastmod: updatedAt }); + posts.forEach((post) => + sitemap.write({ url: `/posts/${post.id}`, lastmod: post.modifiedAt }), + ); + tagHistory(posts).forEach(({ tag, modifiedAt }) => + sitemap.write({ url: `?tag=${tag}`, lastmod: modifiedAt }), + ); + sitemap.end(); + + const sm = await streamToPromise(sitemap); + await writeFile( + sitemapPath, + format(sm.toString(), { indentation: ' ', collapseContent: true }), + ); + console.log('sitemap generated.'); +} + +if (path.resolve(process.argv[1] ?? '') === filename) { + const posts = JSON.parse( + await readFile(path.resolve(rootDir, 'posts.json'), 'utf8'), + ) as JSONPost[]; + void generateSitemap(posts); +} diff --git a/src/scripts/worksJson.ts b/src/scripts/worksJson.ts new file mode 100644 index 0000000..5812906 --- /dev/null +++ b/src/scripts/worksJson.ts @@ -0,0 +1,73 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Citeproc from 'citeproc'; +import type { Data as CSLJSON } from 'csl-json'; +import { format } from 'prettier'; +import type { Citation } from '../works/citation'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const rootDir = path.resolve(dirname, '../..'); +const worksPath = path.resolve(rootDir, 'works.json'); +const citeprocDir = path.resolve(rootDir, 'assets', 'citeproc'); +const stylePath = path.resolve(citeprocDir, 'sist02modified.csl'); +const localePath = path.resolve(citeprocDir, 'locales-ja-JP.xml'); + +function removeNullProperties(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== null), + ) as Partial; +} + +function citeproc(data: CSLJSON[], style: string, locale: string): string[] { + const items = data.map((item) => ({ + ...removeNullProperties(item), + id: item.id.toString(), + })); + const sys = { + retrieveLocale: () => locale, + retrieveItem: (id: string) => { + const item = items.find((item) => id === item.id); + if (!item) throw new Error(`citeproc item not found: ${id}`); + return item; + }, + }; + const citeproc = new Citeproc.Engine(sys, style); + citeproc.setOutputFormat('text'); + + citeproc.updateItems(items.map((item) => item.id)); + const bib = citeproc.makeBibliography(); + if (bib === false) return []; + + return bib[1].map((text) => text.replace(/\n$/, '')); +} + +async function appendCitations(items: CSLJSON[]): Promise { + const [style, locale] = await Promise.all([ + readFile(stylePath, 'utf-8'), + readFile(localePath, 'utf-8'), + ]); + const bibTexts = citeproc(items, style, locale); + return items.map((item, i) => ({ + ...item, + _bibliographyText: bibTexts[i], + })); +} + +export async function generateWorksJson(): Promise { + const works = JSON.parse(await readFile(worksPath, 'utf8')) as CSLJSON[]; + const newWorks = await appendCitations(works); + await writeFile( + worksPath, + await format(JSON.stringify(newWorks), { + semi: false, + parser: 'json', + }), + ); + console.log('works generated.'); +} + +if (path.resolve(process.argv[1] ?? '') === filename) { + void generateWorksJson(); +} diff --git a/src/ssg.ts b/src/ssg.ts index 5a8dfde..6d01ddc 100644 --- a/src/ssg.ts +++ b/src/ssg.ts @@ -1,13 +1,17 @@ /* server-side only: do not import client-side code */ -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { articlePage } from './Article'; import { BlogCard } from './BlogCard'; -import { jsonToPosts, Post, JSONPost } from './post'; +import { jsonToPosts } from './post'; +import type { JSONPost, Post } from './post'; -const templatePath = path.resolve(__dirname, '..', 'post.template.html'); -const jsonPath = path.resolve(__dirname, '..', 'posts.json'); -const distPath = path.resolve(__dirname, '..', 'posts'); +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const rootDir = path.resolve(dirname, '..'); +const templatePath = path.resolve(rootDir, 'post.template.html'); +const distDir = path.resolve(rootDir, 'posts'); function createTemplateValues(post: Post): Record { const page = articlePage(post, true); @@ -23,29 +27,36 @@ function createTemplateValues(post: Post): Record { function embedTemplate(post: Post, template: string, posts: Post[]): string { const values = createTemplateValues(post); return template - .replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + .replaceAll(/\{\{(\w+)\}\}/g, (_, key: string) => { const value = values[key]; if (value === undefined) console.log(`Missing template var: ${key}`); return value; }) - .replace(/<\/blog-card>/g, (_, id: string) => { + .replaceAll(/<\/blog-card>/g, (_, id: string) => { const post = posts.find((post) => id === post.id.toString()); if (!post) return ''; return `${BlogCard(post)}`; }); } -export function generateStaticHTML(): void { - const template = readFileSync(templatePath, 'utf8'); - const posts = jsonToPosts( - JSON.parse(readFileSync(jsonPath, 'utf8')) as JSONPost[], - ); - posts.forEach((post) => - writeFileSync( - path.resolve(distPath, `${post.id}.html`), - embedTemplate(post, template, posts), - 'utf8', +export async function generateStaticHtml(jsonPosts: JSONPost[]): Promise { + const template = await readFile(templatePath, 'utf8'); + const posts = jsonToPosts(jsonPosts); + await Promise.all( + posts.map((post) => + writeFile( + path.resolve(distDir, `${post.id}.html`), + embedTemplate(post, template, posts), + 'utf8', + ), ), ); console.log('static html generated.'); } + +if (path.resolve(process.argv[1] ?? '') === filename) { + const posts = JSON.parse( + await readFile(path.resolve(rootDir, 'posts.json'), 'utf8'), + ) as JSONPost[]; + void generateStaticHtml(posts); +} diff --git a/src/state.ts b/src/state.ts index 2176c44..3885962 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,8 +1,8 @@ type State = { - id: number | undefined; - legacyId: number | undefined; - tag: string | undefined; - keyword: string | undefined; + id: number | null; + legacyId: number | null; + tag: string | null; + keyword: string | null; }; export function toState( @@ -11,21 +11,23 @@ export function toState( locationHash?: string, ): State { const idStr = /\/posts\/(\d+)/.exec(locationPathname)?.[1]; - const id = idStr ? Number(idStr) : undefined; + const id = idStr ? Number(idStr) : null; const searchParams = new URLSearchParams(locationSearch); const tag = searchParams.get('tag'); const legacyIdStr = searchParams.get('id'); const legacyId = - legacyIdStr && /^\d+$/.test(legacyIdStr) ? Number(legacyIdStr) : undefined; + legacyIdStr && /^\d+$/.test(legacyIdStr) ? Number(legacyIdStr) : null; + + const keyword = + locationHash === undefined || locationHash === '' + ? null + : decodeURIComponent(locationHash.replace('#', '')); return { id: id ?? legacyId, legacyId, - tag: tag ?? undefined, - keyword: - locationHash === undefined || locationHash === '' - ? undefined - : decodeURIComponent(locationHash.replace('#', '')), + tag, + keyword, }; } diff --git a/src/works/Works.ts b/src/works/Works.ts index 05290f6..591b132 100644 --- a/src/works/Works.ts +++ b/src/works/Works.ts @@ -1,6 +1,6 @@ import { parseMarkdownLink } from '../lib/ExternalLink'; import { kerningDoubleDash } from '../lib/MarkupText'; -import { Citation, toCitationMap, Genre } from './citation'; +import { toCitationMap, type Citation, Genre } from './citation'; import { isUnpublished } from './citationDate'; const Text = (text: string): string => diff --git a/src/works/citation.ts b/src/works/citation.ts index 2d334c4..a398fff 100644 --- a/src/works/citation.ts +++ b/src/works/citation.ts @@ -1,7 +1,6 @@ -// eslint-disable-next-line import/no-unresolved -import { Data } from 'csl-json'; +import type { Data as CSLJSON } from 'csl-json'; -export type Citation = Omit & { +export type Citation = Omit & { id: string | number; _bibliographyText: string; }; @@ -13,17 +12,13 @@ function detectGenre(item: Citation): Genre | undefined { switch (item.type) { case 'book': return '書籍'; - break; case 'article-journal': if (item.translator) return '翻訳'; return '論文'; - break; case 'paper-conference': return '発表'; - break; case 'thesis': return '学位論文'; - break; default: return undefined; } diff --git a/src/works/citationDate.ts b/src/works/citationDate.ts index fbd4ab3..b94b493 100644 --- a/src/works/citationDate.ts +++ b/src/works/citationDate.ts @@ -1,4 +1,4 @@ -import { Citation } from './citation'; +import type { Citation } from './citation'; type DateParts = [number, number, number]; diff --git a/src/works/index.ts b/src/works/index.ts index de32421..f5488b6 100644 --- a/src/works/index.ts +++ b/src/works/index.ts @@ -1,8 +1,8 @@ -import { renderRoot } from '../lib/render'; import { fetcher } from '../lib/utils'; -import { notifyUpdate, Update } from '../notify'; +import { notifyUpdate, type Update } from '../notify'; +import { renderRoot } from '../render'; import { Works } from './Works'; -import { Citation } from './citation'; +import type { Citation } from './citation'; const worksPath = './works.json'; const aboutPath = './about.json'; diff --git a/tsconfig.checkjs.json b/tsconfig.checkjs.json deleted file mode 100644 index 0974eb7..0000000 --- a/tsconfig.checkjs.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "./", - "allowJs": true, - "checkJs": true, - "noImplicitAny": false - }, - "include": [ - "generateBibliography.js", - "generatePosts.js", - "generateSitemap.js" - ] -} diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index 742e15e..0000000 --- a/webpack.config.js +++ /dev/null @@ -1,27 +0,0 @@ -const path = require('path'); - -module.exports = { - mode: 'production', - entry: { - index: path.join(__dirname, 'src', 'index.ts'), - works: path.join(__dirname, 'src', 'works', 'index.ts'), - hydrate: path.join(__dirname, 'src', 'hydrate.ts'), - }, - output: { - path: path.join(__dirname, 'dist'), - filename: '[name].js', - hashFunction: 'sha256', - }, - module: { - rules: [ - { - test: /\.ts$/, - exclude: /node_modules/, - use: [{ loader: 'ts-loader' }], - }, - ], - }, - resolve: { - extensions: ['.ts', '.js'], - }, -}; diff --git a/webpack.config.mjs b/webpack.config.mjs new file mode 100644 index 0000000..d2baf14 --- /dev/null +++ b/webpack.config.mjs @@ -0,0 +1,32 @@ +// @ts-check +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); + +export default { + mode: 'production', + entry: { + index: path.join(dirname, 'src', 'index.ts'), + works: path.join(dirname, 'src', 'works', 'index.ts'), + hydrate: path.join(dirname, 'src', 'hydrate.ts'), + }, + output: { + path: path.join(dirname, 'dist'), + filename: '[name].js', + hashFunction: 'sha256', + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ loader: 'ts-loader' }], + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, +}; diff --git a/webpack.dev.config.js b/webpack.dev.config.mjs similarity index 78% rename from webpack.dev.config.js rename to webpack.dev.config.mjs index c80da8c..55b7548 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.mjs @@ -1,7 +1,7 @@ -const path = require('path'); -const productionConfig = require('./webpack.config'); +// @ts-check +import productionConfig from './webpack.config.mjs'; -module.exports = { +export default { ...productionConfig, mode: 'development', output: {