diff --git a/bin/build/split.pl b/bin/build/split.pl index cbdd367..40a6f31 100755 --- a/bin/build/split.pl +++ b/bin/build/split.pl @@ -24,7 +24,7 @@ $, = "\n"; # set output field separator $\ = "\n"; # set output record separator my $outFilePrefix = 'md/pages'; -my $sequence = 0; +my $sequence = 0; # Search is -2, Contents is -1 my $thisPart = ''; my $thisChapter = ''; my $thisSection = ''; diff --git a/gatsby-config.js b/gatsby-config.js index 7a57478..9ecf83e 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -87,5 +87,39 @@ module.exports = { disableCookies: true, }, }, + { + resolve: 'my-search-index', + options: { + enabled: true, + chunkTypes: { + p: 'Paragraph', + li: 'List item', + pre: 'Code', + table: 'Table', + h3: 'Heading', + h4: 'Heading', + h5: 'Heading', + h6: 'Heading', + }, + // Note, only pages under src/md/pages have a "hide" property + pageFilter: '{frontmatter: {hide: {eq: false}}}', + exclude: { + // Speed up the build (these are excluded from the index by pageFilter, anyway) + pages: ['/404.html', '/annotated-spec/', '/contact/', '/contents/', '/search/', '/'], + tags: ['nav', 'footer', 'aside', 'svg', 'details', 'mtable', 'mrow'], + attributes: [ + {name: 'id', value: 'page-navi'}, + {name: 'class', value: 'prevnext'}, + {name: 'aria-hidden', value: 'true'}, + {name: 'id', value: 'gatsby-announcer'}, + {name: 'class', value: 'fn-span'}, + {name: 'class', value: 'footnote-ref'}, + ], + } + }, + } ], + flags: { + DEV_SSR: true, + }, }; diff --git a/package-lock.json b/package-lock.json index 23ea289..4d790c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9668,11 +9668,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" - }, "node_modules/hast-util-to-html": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-7.1.3.tgz", @@ -12567,6 +12562,11 @@ "parse-path": "^7.0.0" } }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, "node_modules/parseqs": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz", @@ -14203,11 +14203,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/rehype-parse/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" - }, "node_modules/relay-runtime": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", diff --git a/plugins/my-search-index/gatsby-node.js b/plugins/my-search-index/gatsby-node.js new file mode 100644 index 0000000..4512cd8 --- /dev/null +++ b/plugins/my-search-index/gatsby-node.js @@ -0,0 +1,148 @@ +const parse5 = require('parse5') +const { isExcluded, getId, addIdToTags } = require('./util.js') + +// Creates a GraphQL node containing data for the local search + +// Concatenate all text in child nodes. +const getText = (node, exclude) => { + + let text = '' + + if (isExcluded(node, exclude)) { + return text + } + + if (node.nodeName === '#text') { + text += node.value + } + + for (let i = 0; i < node.childNodes?.length; i++) { + text += getText(node.childNodes[i], exclude) + } + + return text +} + +// Recurse until we find an element we want to treat as a chunk, then get all its text content. +const getChunks = (node, chunkTypes, exclude) => { + + const chunks = [] + + if (isExcluded(node, exclude)) { + return chunks + } + + if (Object.keys(chunkTypes).indexOf(node.nodeName) !== -1) { + const text = getText(node, exclude) + if (text !== '') { + chunks.push( + { + type: node.nodeName, + label: chunkTypes[node.nodeName], + id: getId(node), // We previously added an id, so it should be there. + text: text, + } + ) + } + } + + for (let i = 0; i < node.childNodes?.length; i++) { + chunks.push(getChunks(node.childNodes[i], chunkTypes, exclude)) + } + + return chunks.flat() +} + +exports.createPages = async ( + { + actions, + graphql, + reporter, + createNodeId, + createContentDigest, + }, pluginOptions, +) => { + + const { + enabled = true, + chunkTypes = {}, + pageFilter = '{}', + exclude = {pages: [], tags: [], attributes: []}, + } = pluginOptions + + const mySearchData = [] + + if (enabled) { + + const result = await graphql(` + { + allMarkdownRemark(filter: ${pageFilter}) { + edges { + node { + frontmatter { + path + } + } + } + } + } + `) + + const pages = result.data.allMarkdownRemark.edges + + await Promise.all(pages.map(async (page) => { + + const path = page.node.frontmatter.path + if (exclude.pages.indexOf(path) == -1) { + + // Get the raw HTML. We could get the htmlAst directly from the node, + // but the parse5 format is easier to deal with. + const htmlData = await graphql(` + { + markdownRemark(frontmatter: {path: {eq: "${path}"}}) { + html + frontmatter { + titles + } + } + } + `) + + const htmlAst = parse5.parse(htmlData.data.markdownRemark.html) + const frontmatter = htmlData.data.markdownRemark.frontmatter + + // Changes to the HTML AST made here will not persist, but we need to do + // exactly the same as in gatsby-ssr so that our ids end up consistent. + Object.keys(chunkTypes).forEach(tag => { + addIdToTags(htmlAst, tag, exclude) + }) + + const chunks = getChunks(htmlAst, chunkTypes, exclude) + + mySearchData.push({ + path: path, + title: frontmatter.titles.filter(x => x !== "").join(" | "), + chunks: chunks, + }) + } + })) + } + + name = 'mySearchData' + actions.createNode({ + id: createNodeId(name), + data: mySearchData, + internal: { + type: name, + contentDigest: createContentDigest(mySearchData) + } + }) +} + +exports.createSchemaCustomization = ({ actions: { createTypes } }) => { + createTypes(` + type mySearchData implements Node { + data: JSON + } + `) +} diff --git a/plugins/my-search-index/gatsby-ssr.js b/plugins/my-search-index/gatsby-ssr.js new file mode 100644 index 0000000..451751d --- /dev/null +++ b/plugins/my-search-index/gatsby-ssr.js @@ -0,0 +1,45 @@ +const { renderToString } = require('react-dom/server') +const parse5 = require('parse5') +const { addIdToTags } = require('./util.js') + +// Adds ID anchors to all elements that might appear in the local search + +const findBody = (node) => { + + if (node.tagName === 'body') { + return node + } + + for (let i = 0; i < node.childNodes?.length; i++) { + let maybeBody = findBody(node.childNodes[i]) + if(maybeBody != null) { + return maybeBody + } + } + + return null +} + +exports.replaceRenderer = ({ pathname, bodyComponent, replaceBodyHTMLString }, pluginOptions) => { + + const { + enabled = true, + chunkTypes = {}, + exclude = {pages: [], tags: [], attributes: []}, + } = pluginOptions + + if (enabled && exclude.pages.indexOf(pathname) == -1) { + + // Get the HTML + const bodyHTML = renderToString(bodyComponent) + const body = findBody(parse5.parse(bodyHTML)) + + // Change the HTML + Object.keys(chunkTypes).forEach(tag => { + addIdToTags(body, tag, exclude) + }) + + // Replace the HTML + replaceBodyHTMLString(parse5.serialize(body)) + } +} diff --git a/plugins/my-search-index/index.js b/plugins/my-search-index/index.js new file mode 100644 index 0000000..e89c38a --- /dev/null +++ b/plugins/my-search-index/index.js @@ -0,0 +1 @@ +// no-op diff --git a/plugins/my-search-index/package.json b/plugins/my-search-index/package.json new file mode 100644 index 0000000..067539b --- /dev/null +++ b/plugins/my-search-index/package.json @@ -0,0 +1,11 @@ +{ + "name": "my-search-index", + "version": "1.0.0", + "description": "Build the search index", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Ben Edgington", + "license": "ISC" +} diff --git a/plugins/my-search-index/util.js b/plugins/my-search-index/util.js new file mode 100644 index 0000000..3975afa --- /dev/null +++ b/plugins/my-search-index/util.js @@ -0,0 +1,51 @@ +const attributesAreEqual = (att1, att2) => { + return (att1.name === att2.name && att1.value === att2.value) +} + +const isExcluded = (node, exclude) => { + + if (exclude.tags.indexOf(node.tagName) != -1) { + return true + } + + if (node.attrs) { + for (let i = 0; i < node.attrs.length; i++) { + for (let j = 0; j < exclude.attributes.length; j++) { + if (attributesAreEqual(node.attrs[i], exclude.attributes[j])) { + return true + } + } + } + } + + return false +} + +const getId = (node) => { + if (node.attrs !== undefined) { + const x = node.attrs.filter(attr => (attr.name === 'id')) + if (x.length > 0) { + return x[0].value + } + } +} + +const addIdToTags = (node, tag, exclude, totalDone = 0) => { + + if (isExcluded(node, exclude)) { + return totalDone + } + + if (node.tagName === tag && getId(node) == undefined) { + node.attrs.push({name: 'id', value: tag + '_' + totalDone}) + totalDone++; + } + + for (let i = 0; i < node.childNodes?.length; i++) { + totalDone = addIdToTags(node.childNodes[i], tag, exclude, totalDone) + } + + return totalDone +} + +module.exports = { isExcluded, getId, addIdToTags } diff --git a/src/components/prevnext.js b/src/components/prevnext.js index 7746a5c..47aef79 100644 --- a/src/components/prevnext.js +++ b/src/components/prevnext.js @@ -5,12 +5,13 @@ import { useStaticQuery, graphql } from "gatsby" import "../css/prevnext.css" function PrevNextLink(props) { - if (props.page === null || props.page === undefined) return null + if (props.page === null + || props.page === undefined + || props.page.node.frontmatter.sequence < 0 + ) return null const f = props.page.node.frontmatter - var title = f.titles[0] - if (f.titles[1] !== "") title += " > " + f.titles[1] - if (f.titles[2] !== "") title += " > " + f.titles[2] + let title = f.titles.filter(x => x !== "").join(" > ") return( {props.children} @@ -38,8 +39,6 @@ const PrevNext = (props) => { const pages = data.allMarkdownRemark.edges - // console.log(JSON.stringify(pages, undefined, 2)) - const prevPage = pages.filter(p => p.node.frontmatter.sequence === (props.seq - 1))[0] const nextPage = pages.filter(p => p.node.frontmatter.sequence === (props.seq + 1))[0] diff --git a/src/components/search.js b/src/components/search.js new file mode 100644 index 0000000..098d605 --- /dev/null +++ b/src/components/search.js @@ -0,0 +1,153 @@ +import React from 'react' +import { graphql, useStaticQuery, Link, withPrefix } from 'gatsby' + +import "../css/search.css" + +const getSearchResults = (query, data) => { + + if (query.searchText.length < 3) { + return [] + } + + // Match the starts of words only. The "d" flag gives us the matching indices. + const regex = RegExp('(^|\\s)' + query.searchText, 'gd' + (query.isCaseSensitive ? '' : 'i')) + + const result = data.map( (page) => { + + let score = 0 + const matches = [] + for (let i = 0; i < page.chunks?.length; i++) { + + let chunk = page.chunks[i] + let match + const indices = [] + while ((match = regex.exec(chunk.text)) !== null) { + // Remove whitespace from start of match, except at the very beginning of the string. + indices.push([match.indices[0][0] === 0 ? 0 : match.indices[0][0] + 1, match.indices[0][1]]) + } + if (indices.length > 0) { + matches.push( + { + type: chunk.type, + label: chunk.label, + id: chunk.id, + text: chunk.text, + indices: indices, + } + ) + score += indices.length + } + } + + return matches.length === 0 ? null : { + url: page.path, + title: page.title, + matches: matches, + score: score, + } + }) + + return result.filter(x => x !== null).sort((a, b) => (b.score - a.score)) +} + +const Search = () => { + + const queryData = useStaticQuery(graphql` + query { + mySearchData { + data + } + } + `) + + const searchData = queryData.mySearchData.data + + const [searchQuery, setQuery] = React.useState({ + searchText: '', + isCaseSensitive: false, + }) + + const setSearchText = (text) => { + setQuery(previousState => { + return { ...previousState, searchText: text } + }); + } + + const toggleIsCaseSensitive = () => { + setQuery(previousState => { + return { ...previousState, isCaseSensitive: !previousState.isCaseSensitive } + }); + } + + const results = getSearchResults(searchQuery, searchData) + + const pages = results.map((result) => { + const chunks = result.matches.map((match) => { + const matches = match.indices.map((indices, i) => { + return [ + match.text.substring((i === 0) ? 0 : match.indices[i-1][1], indices[0]), + + {match.text.substring(indices[0], indices[1])} + , + (i === match.indices.length -1) ? match.text.substring(indices[1]) : '', + ] + }) + return ( +
  • + + {match.label} + + + {matches} + +
  • + ) + }) + return ( +
  • + {result.title} + +
  • + ) + }) + + return ( +
    +
    + setSearchText(event.target.value)} + /> + + + + +
    +
    + {results.length > 0 ? ( + + ) : ( +

    No results

    + )} +
    +
    + ) +} + +export default Search diff --git a/src/css/search.css b/src/css/search.css new file mode 100644 index 0000000..9f65596 --- /dev/null +++ b/src/css/search.css @@ -0,0 +1,74 @@ +@import "custom.css"; + +#search-parameters { + margin-bottom: 2rem; +} + +#search-parameters input#search-text { + background-color: var(--background); + color: var(--foreground); + padding: 6px; + border: 1px solid #ccc; + font-size: 1rem; + width: 50%; +} + +#search-parameters input#search-text:focus { + outline: none; +} + +#search-parameters span#checkbox { + margin: 12px; +} + +#search-parameters span#checkbox label { + padding-left: 0.2rem; + font-family: sans-serif; + font-size: 1rem; +} + +#search-parameters input#is-case-sensitive { + width:1rem; + height:1rem; +} + +#search-results ul { + padding-left: 0; + margin-left: 0; + list-style-type: none; +} + +#search-results ul li ul{ + padding-left: 0.5rem; +} + +#search-results ul li ul li{ + margin: 0.8rem 0; + padding: 0.5rem; + background: var(--search-chunk-background); + border-radius: 0.75rem; +} + +#search-results a.label { + display: block; + font-family: sans-serif; + font-size: 0.9rem; +} + +#search-results span.chunk-text.pre { + white-space: pre; + font-family: monospace; + font-size: 80%; +} + +#search-results > ul li { + padding-bottom: 1ex; +} + +#search-results > ul li:last-child { + margin-bottom: 1ex; +} + +#search-results span.match-text { + background-color: var(--search-text-highlight); +} diff --git a/src/md/contents.md b/src/md/contents.md index bb88b0a..f4b478a 100644 --- a/src/md/contents.md +++ b/src/md/contents.md @@ -2,7 +2,7 @@ path: /contents/ titles: ["Contents","",""] index: [-1] -sequence: 0 +sequence: -1 --- # Contents diff --git a/src/md/search.md b/src/md/search.md new file mode 100644 index 0000000..3e07514 --- /dev/null +++ b/src/md/search.md @@ -0,0 +1,9 @@ +--- +path: /search/ +titles: ["Search","",""] +index: [-1] +sequence: -2 +--- + +# Search + diff --git a/src/templates/pageTemplate.js b/src/templates/pageTemplate.js index 0040728..a9b95eb 100644 --- a/src/templates/pageTemplate.js +++ b/src/templates/pageTemplate.js @@ -10,6 +10,7 @@ import PageNavi from "../components/pagenavi" import FootnoteTooltips from "../components/footnote-tooltips" import DarkModeToggle from "../components/dark-mode-toggle" import PrintScripts from "../components/print-scripts" +import Search from "../components/search" import "katex/dist/katex.min.css" import "../css/page.css" @@ -19,9 +20,7 @@ export function Head({ data }) { const { markdownRemark, site } = data const { frontmatter } = markdownRemark - const indexArray = frontmatter.path !== "/contents" - ? frontmatter.index - : [] + const indexArray = frontmatter.index var pageTitle = site.siteMetadata.title if (frontmatter.titles !== null) { @@ -38,12 +37,11 @@ export function Head({ data }) { export default function Template({ data }) { const { html, frontmatter } = data.markdownRemark + const indexArray = frontmatter.index - //console.log(JSON.stringify(markdownRemark, undefined, 2)) - - const indexArray = frontmatter.path !== "/contents" - ? frontmatter.index - : [] + const pageExtras = frontmatter.path.startsWith('/search') + ? + : return ( <> @@ -59,7 +57,7 @@ export default function Template({ data }) { className="section-content" dangerouslySetInnerHTML={{ __html: html }} /> - + {pageExtras}