8000 Adding Site-Wide Search to Ignite by JPToroDev · Pull Request #819 · twostraws/Ignite · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Adding Site-Wide Search to Ignite #819

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension 8000

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/Ignite/Elements/Body.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ public struct Body: MarkupElement {

output += Script(file: "/js/ignite-core.js").markup()

if publishingContext.isSearchEnabled {
output += Script(file: "/js/lunr.js").markup()
output += Script(file: "/js/search.js").markup()
}

if isBoundByContainer {
attributes.append(classes: ["container"])
}
Expand Down
229 changes: 229 additions & 0 deletions Sources/Ignite/Elements/SearchForm.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//
// SearchForm.swift
// Ignite
// https://www.github.com/twostraws/Ignite
// See LICENSE for license information.
//

/// An action that triggers the search functionality.
struct SearchAction: Action {
func compile() -> String {
"performSearch(document.querySelector('[id^=\'search-input-\']').value)"
}
}

/// A form that performs site-wide search.
public struct SearchForm: HTML, NavigationItem {
/// The appearance of the search-button label.
public enum SearchButtonStyle: Sendable, Equatable, CaseIterable {
case iconOnly, titleAndIcon, titleOnly
}

/// The content and behavior of this HTML.
public var body: some HTML { self }

/// The standard set of control attributes for HTML elements.
public var attributes = CoreAttributes()

/// Whether this HTML belongs to the framework.
public var isPrimitive: Bool { true }

/// The view displayed for each search result.
private var resultView: any HTML

/// The view displayed when there are no results.
private var noResultsView: any HTML

/// A view displayed at the top of the search results page.
private var resultsPageHeader: any HTML

/// This text provides a hint to users about what they can search for.
private var prompt: String = "Search"

/// How a `NavigationBar` displays this item at different breakpoints.
public var navigationBarVisibility: NavigationBarVisibility = .automatic

/// Whether this dropdown needs to be created as its own element,
/// or whether it uses the structure provided by a parent `NavigationBar`.
var isNavigationItem = false

/// The text displayed on the search button.
private var searchButtonLabel = "Search"

/// The appearance of the search-button label.
private var searchButtonStyle: SearchButtonStyle = .iconOnly

/// The visual style of the search button.
private var searchButtonRole: Role = .primary

/// The size of the form controls
private var controlSize: ControlSize = .medium

/// The text color for the search button.
private var searchButtonForegroundStyle: Color?

/// Whether the search results HTML `<template>` block should be included.
private var isSearchResultsTemplateHidden = false

/// The identifier associated with this form.
private let searchID = UUID().uuidString.truncatedHash

/// Creates a new search field with customizable result view.
/// - Parameters:
/// - resultView: A closure that returns a custom view for displaying search result data.
/// - resultsPageHeader: A closure that returns a custom view to display
/// at the top of the search results page.
public init(
@HTMLBuilder resultView: (_ result: SearchResult) -> some HTML,
@HTMLBuilder noResultsView: () -> some HTML = { EmptyHTML() },
@HTMLBuilder resultsPageHeader: () -> some HTML = { EmptyHTML() }
) {
self.resultView = resultView(SearchResult())
self.noResultsView = noResultsView()
self.resultsPageHeader = resultsPageHeader()
publishingContext.isSearchEnabled = true
}

/// Sets the text displayed on the search button.
/// - Parameter label: The text to display on the button.
/// - Returns: A modified form with the updated button text.
public func searchButtonLabel(_ label: String) -> Self {
var copy = self
copy.searchButtonLabel = label
return copy
}

/// Sets the icon and label visibility of the search button.
/// - Parameter style: The style to apply to the button.
/// - Returns: A modified form with the updated button style.
public func searchButtonStyle(_ style: SearchButtonStyle) -> Self {
var copy = self
copy.searchButtonStyle = style
return copy
}

/// Sets the size of form controls and labels
/// - Parameter size: The desired size
/// - Returns: A modified form with the specified control size
public func controlSize(_ size: ControlSize) -> Self {
var copy = self
copy.controlSize = size
return copy
}

/// Sets the visual role of the search button.
/// - Parameter role: The role determining the button's appearance.
/// - Returns: A modified form with the specified button role.
public func searchButtonRole(_ role: Role) -> Self {
var copy = self
copy.searchButtonRole = role
return copy
}

/// Sets the text color of the search button.
/// - Parameter style: The color to apply to the button text.
/// - Returns: A modified form with the specified button text color.
public func searchButtonForegroundStyle(_ style: Color) -> Self {
var copy = self
copy.searchButtonForegroundStyle = style
return copy
}

/// Sets the placeholder text for the search input field.
/// - Parameter prompt: The text to display when the input is empty.
/// - Returns: A modified search form with the new placeholder text.
public func searchPrompt(_ prompt: String) -> Self {
var copy = self
copy.prompt = prompt
return copy
}

/// Hides the search results `<template>` block from the rendered HTML.
/// - Returns: A modified form with the specified template visibility.
private func searchResultsTemplateHidden() -> Self {
var copy = self
copy.isSearchResultsTemplateHidden = true
return copy
}

private func renderForm() -> Markup {
Form(spacing: .none) {
Section {
TextField("Search", prompt: prompt)
.id("search-input-\(searchID)")
.labelStyle(.hidden)
.size(controlSize)
.customAttribute(name: "inputmode", value: "search")
.style(.paddingRight, "35px")

Button(Span("").class("bi bi-x-circle-fill"))
.style(.position, "absolute")
.style(.zIndex, "100")
.style(.right, "0px")
.style(.top, "0px")
.style(.display, "none")
}
.style(.flex, "1")
.class(isNavigationItem ? nil : "me-2")
.style(.position, "relative")

if !isNavigationItem {
Button(
searchButtonStyle != .iconOnly ? searchButtonLabel : "",
systemImage: searchButtonStyle != .titleOnly ? "search" : nil
) {
SearchAction()
}
.type(.submit)
.role(searchButtonRole)
.style(.color, searchButtonForegroundStyle != nil ?
searchButtonForegroundStyle!.description : "")
}
}
.configuredAsNavigationItem(isNavigationItem)
.controlSize(controlSize)
.labelStyle(.hidden)
.class("align-items-center")
.customAttribute(name: "role", value: "search")
.customAttribute(name: "onsubmit", value: "return false")
.id("search-form-\(searchID)")
.style(.minWidth, "125px")
.attributes(attributes)
.markup()
}

private func renderTemplate() -> Markup {
Tag("template") {
if resultsPageHeader.isEmpty == false {
AnyHTML(resultsPageHeader)
.class("search-results-header")
}

SearchForm { _ in EmptyHTML() }
.searchResultsTemplateHidden()
.class("results-search-form")
.margin(.bottom)

Section(resultView)
.class("search-results-item")
.margin(.bottom, .medium)

if noResultsView.isEmpty == false {
AnyHTML(noResultsView)
.class("no-results-view")
}
}
.id("search-results-\(searchID)")
.markup()
}

public func markup() -> Markup {
var output = renderForm()
if !isSearchResultsTemplateHidden {
output += renderTemplate()
}
return output
}
}

extension SearchForm: NavigationItemConfigurable {}
22 changes: 22 additions & 0 deletions Sources/Ignite/Framework/SearchResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// SearchResult.swift
// Ignite
// https://www.github.com/twostraws/Ignite
// See LICENSE for license information.
//

/// A template for displaying individual search results.
@MainActor
public struct SearchResult {
/// The title of the search result.
public var title: some HTML = Text("").class("result-title")

/// A brief description or excerpt of the search result content.
public var description: some HTML = Text("").class("result-description")

/// The publication date of the content.
public var date: (some HTML)? = Text("").class("result-date")

/// Optional tags associated with the search result.
public var tags: (some HTML)? = Text("").class("result-tags")
}
21 changes: 21 additions & 0 deletions Sources/Ignite/Publishing/PublishingContext-Rendering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ extension PublishingContext {

let outputDirectory = buildDirectory.appending(path: path)
write(outputString, to: outputDirectory, priority: priority, filename: filename)

if isSearchEnabled {
if page.description.isEmpty {
addWarning("\(page.title) lacks a page description and will be omitted from search results.")
} else {
let pageSearchMetadata = SearchMetadata(
id: path,
title: pageMetadata.title,
description: pageMetadata.description)
searchMetadata.append(pageSearchMetadata)
}
}
}

/// Renders one piece of Markdown content.
Expand Down Expand Up @@ -86,6 +98,15 @@ extension PublishingContext {

let outputDirectory = buildDirectory.appending(path: article.path)
write(outputString, to: outputDirectory, priority: 0.8)

let pageSearchMetadata = SearchMetadata(
id: article.path,
title: article.title,
description: article.description,
tags: article.tags,
date: article.date)

searchMetadata.append(pageSearchMetadata)
}

/// Generates all tags pages, including the "all tags" page.
Expand Down
32 changes: 32 additions & 0 deletions Sources/Ignite/Publishing/PublishingContext-SearchIndex.swift
Original file line number Diff line number Diff line change @@ -0,0 +1,32 @@ // // PublishingContext-SearchIndex.swift // Ignite // https://www.github.com/twostraws/Ignite // See LICENSE for license information. //
import Foundation
extension PublishingContext { /// Generates a search index for all articles in the site. /// The index is saved as a JSON file that can be loaded by Lunr.js on the client side. func generateSearchIndex() { let searchableDocuments = searchMetadata.map { metadatum -> [String: Any] in return [ "id": metadatum.id, "title": metadatum.title, "description": metadatum.description, "tags": metadatum.tags?.joined(separator: ",") ?? "", "date": metadatum.date?.formatted(date: .long, time: .omitted) ?? "" ] }
do { let jsonData = try JSONSerialization.data(withJSONObject: searchableDocuments, options: .prettyPrinted) let outputPath = buildDirectory.appending(path: "search-index.json") try jsonData.write(to: outputPath) } catch { addError(.failedToWriteFile("search-index.json")) } } }
12 changes: 12 additions & 0 deletions Sources/Ignite/Publishing/PublishingContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ final class PublishingContext {
/// An ordered set of syntax highlighters pulled from code blocks throughout the site.
var syntaxHighlighters = OrderedSet<HighlighterLanguage>()

/// Whether the site has enabled searching articles.
var isSearchEnabled: Bool = false

/// The metadata of every searchable page.
var searchMetadata: [SearchMetadata] = []

/// Whether the site uses syntax highlighters.
var hasSyntaxHighlighters: Bool {
!syntaxHighlighters.isEmpty || !site.syntaxHighlighterConfiguration.languages.isEmpty
Expand Down Expand Up @@ -188,6 +194,7 @@ final class PublishingContext {
func publish() async throws {
clearBuildFolder()
await generateContent()
generateSearchIndex()
copyResources()
generateThemes(site.allThemes)
generateMediaQueryCSS()
Expand Down Expand Up @@ -237,6 +244,11 @@ final class PublishingContext {
copy(resource: "fonts/bootstrap-icons.woff2")
}

if isSearchEnabled {
copy(resource: "js/lunr.js")
copy(resource: "js/search.js")
}

if hasSyntaxHighlighters {
copy(resource: "js/prism-core.js")
copy(resource: "css/prism-plugins.css")
Expand Down
25 changes: 25 additions & 0 deletions Sources/Ignite/Publishing/SearchMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// SearchMetadata.swift
// Ignite
// https://www.github.com/twostraws/Ignite
// See LICENSE for license information.
//

/// Metadata for a searchable document, containing its unique identifier,
/// title, description, and optional tags and date.
struct SearchMetadata: Sendable {
/// A unique identifier for the document.
var id: String

/// The document's title.
var title: String

/// A brief description of the document's contents.
var description: String

/// Tags that categorize the document.
var tags: [String]?

/// When the document was created or last modified.
var date: Date?
}
Loading
0