From 4094dcb61950d84a881dd6942d61037a2e1de389 Mon Sep 17 00:00:00 2001 From: Sebastien Rousseau Date: Sat, 21 Sep 2024 12:14:19 +0100 Subject: [PATCH] =?UTF-8?q?refactor(ssg):=20=F0=9F=8E=A8=20refactor=20ssg-?= =?UTF-8?q?metadata,=20tests=20&=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 4 + ssg-metadata/.deepsource.toml | 8 + ssg-metadata/.editorconfig | 29 ++ ssg-metadata/.gitattributes | 9 + ssg-metadata/.gitignore | 12 + ssg-metadata/Cargo.toml | 4 + ssg-metadata/LICENSE-APACHE | 202 +++++++++++++ ssg-metadata/LICENSE-MIT | 21 ++ ssg-metadata/Makefile | 86 ++++++ ssg-metadata/deny.toml | 21 ++ ssg-metadata/rustfmt.toml | 32 +++ ssg-metadata/src/error.rs | 37 +++ ssg-metadata/src/escape.rs | 25 -- ssg-metadata/src/extractor.rs | 76 ----- ssg-metadata/src/keywords.rs | 37 --- ssg-metadata/src/lib.rs | 84 +++--- ssg-metadata/src/macros/mod.rs | 208 -------------- ssg-metadata/src/metadata.rs | 363 +++++++++++++++++++++++ ssg-metadata/src/metatags.rs | 368 +++++++++++------------- ssg-metadata/src/models/mod.rs | 223 -------------- ssg-metadata/src/processor.rs | 55 ---- ssg-metadata/src/utils.rs | 123 ++++++++ ssg-metadata/tests/integration_tests.rs | 1 - ssg-metadata/tests/test_error.rs | 75 +++++ ssg-metadata/tests/test_integration.rs | 152 ++++++++++ ssg-metadata/tests/test_lib.rs | 115 ++++++++ ssg-metadata/tests/test_metadata.rs | 122 ++++++++ ssg-metadata/tests/test_metatags.rs | 51 ++++ ssg-metadata/tests/test_utils.rs | 83 ++++++ 29 files changed, 1758 insertions(+), 868 deletions(-) create mode 100644 ssg-metadata/.deepsource.toml create mode 100644 ssg-metadata/.editorconfig create mode 100644 ssg-metadata/.gitattributes create mode 100644 ssg-metadata/.gitignore create mode 100644 ssg-metadata/LICENSE-APACHE create mode 100644 ssg-metadata/LICENSE-MIT create mode 100644 ssg-metadata/Makefile create mode 100644 ssg-metadata/deny.toml create mode 100644 ssg-metadata/rustfmt.toml create mode 100644 ssg-metadata/src/error.rs delete mode 100644 ssg-metadata/src/escape.rs delete mode 100644 ssg-metadata/src/extractor.rs delete mode 100644 ssg-metadata/src/keywords.rs delete mode 100644 ssg-metadata/src/macros/mod.rs create mode 100644 ssg-metadata/src/metadata.rs delete mode 100644 ssg-metadata/src/models/mod.rs delete mode 100644 ssg-metadata/src/processor.rs create mode 100644 ssg-metadata/src/utils.rs delete mode 100644 ssg-metadata/tests/integration_tests.rs create mode 100644 ssg-metadata/tests/test_error.rs create mode 100644 ssg-metadata/tests/test_integration.rs create mode 100644 ssg-metadata/tests/test_lib.rs create mode 100644 ssg-metadata/tests/test_metadata.rs create mode 100644 ssg-metadata/tests/test_metatags.rs create mode 100644 ssg-metadata/tests/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 8c554517..d4da9cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3217,12 +3217,16 @@ version = "0.0.1" dependencies = [ "anyhow", "assert_fs", + "dtt 0.0.8", "predicates", "quick-xml 0.36.1", "regex", "serde", "serde_json", + "tempfile", "thiserror", + "time", + "tokio", "toml", "version_check", "yaml-rust2", diff --git a/ssg-metadata/.deepsource.toml b/ssg-metadata/.deepsource.toml new file mode 100644 index 00000000..e1aa2aab --- /dev/null +++ b/ssg-metadata/.deepsource.toml @@ -0,0 +1,8 @@ +version = 1 + +[[analyzers]] +name = "rust" +enabled = true + +[analyzers.meta] +msrv = "stable" diff --git a/ssg-metadata/.editorconfig b/ssg-metadata/.editorconfig new file mode 100644 index 00000000..fd099d24 --- /dev/null +++ b/ssg-metadata/.editorconfig @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MIT + +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.rs] +max_line_length = 80 + +[*.md] +# double whitespace at end of line +# denotes a line break in Markdown +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/ssg-metadata/.gitattributes b/ssg-metadata/.gitattributes new file mode 100644 index 00000000..4be15071 --- /dev/null +++ b/ssg-metadata/.gitattributes @@ -0,0 +1,9 @@ +# Auto detect text files and perform normalization +* text=auto eol=lf + +*.rs text diff=rust eol=lf +*.toml text diff=toml eol=lf +Cargo.lock text eol=lf + +*.sh text eol=lf +*.ps1 text eol=crlf \ No newline at end of file diff --git a/ssg-metadata/.gitignore b/ssg-metadata/.gitignore new file mode 100644 index 00000000..70f75b75 --- /dev/null +++ b/ssg-metadata/.gitignore @@ -0,0 +1,12 @@ +*.DS_Store +*.profraw +*.log +/.vscode/ +/output/ +/public/ +/target/ +build +Icon? +src/.DS_Store +tarpaulin-report.html +Cargo.lock diff --git a/ssg-metadata/Cargo.toml b/ssg-metadata/Cargo.toml index 35a996da..1d5a284a 100644 --- a/ssg-metadata/Cargo.toml +++ b/ssg-metadata/Cargo.toml @@ -25,12 +25,16 @@ readme = "README.md" [dependencies] # Dependencies required for building and running the project. +dtt = "0.0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" yaml-rust2 = "0.8" anyhow = "1.0" +tempfile = "3.12" thiserror = "1.0" +tokio = { version = "1.0", features = ["full"] } +time = "0.3" regex = "1.10" quick-xml = "0.36" diff --git a/ssg-metadata/LICENSE-APACHE b/ssg-metadata/LICENSE-APACHE new file mode 100644 index 00000000..ac8e48ce --- /dev/null +++ b/ssg-metadata/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright © 2022-2023 Mini Functions. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://opensource.org/licenses/Apache-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/ssg-metadata/LICENSE-MIT b/ssg-metadata/LICENSE-MIT new file mode 100644 index 00000000..0e6615dc --- /dev/null +++ b/ssg-metadata/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Sebastien Rousseau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ssg-metadata/Makefile b/ssg-metadata/Makefile new file mode 100644 index 00000000..bd1939b0 --- /dev/null +++ b/ssg-metadata/Makefile @@ -0,0 +1,86 @@ +# Makefile using cargo for managing builds and dependencies in a Rust project. + +# Default target executed when no arguments are given to make. +.PHONY: all +all: help ## Display this help. + +# Build the project including all workspace members. +.PHONY: build +build: ## Build the project. + @echo "Building all project components..." + @cargo build --all + +# Lint the project with stringent rules using Clippy, install Clippy if not present. +.PHONY: lint +lint: ensure-clippy ## Lint the project with Clippy. + @echo "Linting with Clippy..." + @cargo clippy --all-features --all-targets --all -- \ + --deny clippy::dbg_macro --deny clippy::unimplemented --deny clippy::todo --deny warnings \ + --deny missing_docs --deny broken_intra_doc_links --forbid unused_must_use --deny clippy::result_unit_err + +# Run all unit and integration tests in the project. +.PHONY: test +test: ## Run tests for the project. + @echo "Running tests..." + @cargo test + +# Check the project for errors without producing outputs. +.PHONY: check +check: ## Check the project for errors without producing outputs. + @echo "Checking code formatting..." + @cargo check + +# Format all code in the project according to rustfmt's standards, install rustfmt if not present. +.PHONY: format +format: ensure-rustfmt ## Format the code. + @echo "Formatting all project components..." + @cargo fmt --all + +# Check code formatting without making changes, with verbose output, install rustfmt if not present. +.PHONY: format-check-verbose +format-check-verbose: ensure-rustfmt ## Check code formatting with verbose output. + @echo "Checking code format with verbose output..." + @cargo fmt --all -- --check --verbose + +# Apply fixes to the project using cargo fix, install cargo-fix if necessary. +.PHONY: fix +fix: ensure-cargo-fix ## Automatically fix Rust lint warnings using cargo fix. + @echo "Applying cargo fix..." + @cargo fix --all + +# Use cargo-deny to check for security vulnerabilities, licensing issues, and more, install if not present. +.PHONY: deny +deny: ensure-cargo-deny ## Run cargo deny checks. + @echo "Running cargo deny checks..." + @cargo deny check + +# Check for outdated dependencies only for the root package, install cargo-outdated if necessary. +.PHONY: outdated +outdated: ensure-cargo-outdated ## Check for outdated dependencies for the root package only. + @echo "Checking for outdated dependencies..." + @cargo outdated --root-deps-only + +# Installation checks and setups +.PHONY: ensure-clippy ensure-rustfmt ensure-cargo-fix ensure-cargo-deny ensure-cargo-outdated +ensure-clippy: + @cargo clippy --version || rustup component add clippy + +ensure-rustfmt: + @cargo fmt --version || rustup component add rustfmt + +ensure-cargo-fix: + @cargo fix --version || rustup component add rustfix + +ensure-cargo-deny: + @command -v cargo-deny || cargo install cargo-deny + +ensure-cargo-outdated: + @command -v cargo-outdated || cargo install cargo-outdated + +# Help target to display callable targets and their descriptions. +.PHONY: help +help: ## Display this help. + @echo "Usage: make [target]..." + @echo "" + @echo "Targets:" + @awk 'BEGIN {FS = ":.*?##"} /^[a-zA-Z_-]+:.*?##/ {printf " %-30s %s\n", $$1, $$2}' $(MAKEFILE_LIST) \ No newline at end of file diff --git a/ssg-metadata/deny.toml b/ssg-metadata/deny.toml new file mode 100644 index 00000000..b58b6035 --- /dev/null +++ b/ssg-metadata/deny.toml @@ -0,0 +1,21 @@ +[licenses] +# List of allowed licenses for dependencies +allow = ["MIT", "Apache-2.0", "Unicode-DFS-2016"] + +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" + +# The graph highlighting used when creating dotgraphs for crates with multiple versions +# Options: "lowest-version", "simplest-path", "all" +highlight = "all" + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [] + +# Similarly to `skip` allows you to skip certain crates during duplicate detection, unlike skip, it also includes the entire tree of transitive dependencies starting at the specified crate, up to a certain depth, which is by default infinite +skip-tree = [] + +[advisories] +# List of advisory IDs to ignore +ignore = [] \ No newline at end of file diff --git a/ssg-metadata/rustfmt.toml b/ssg-metadata/rustfmt.toml new file mode 100644 index 00000000..8667b4e4 --- /dev/null +++ b/ssg-metadata/rustfmt.toml @@ -0,0 +1,32 @@ +# Rust edition to use +edition = "2021" + +# Maximum width of each line +max_width = 72 + +# Number of spaces per tab +tab_spaces = 4 + +# Line ending style +newline_style = "Unix" + +# Use small heuristics for formatting decisions +use_small_heuristics = "Default" + +# Use spaces for indentation, not tabs +hard_tabs = false + +# Merge multiple `derive` attributes into a single attribute +merge_derives = true + +# Reorder import statements +reorder_imports = true + +# Reorder `mod` statements +reorder_modules = true + +# Remove unnecessary nested parentheses +remove_nested_parens = true + +# Always specify the ABI explicitly for external items +force_explicit_abi = true \ No newline at end of file diff --git a/ssg-metadata/src/error.rs b/ssg-metadata/src/error.rs new file mode 100644 index 00000000..92c8a722 --- /dev/null +++ b/ssg-metadata/src/error.rs @@ -0,0 +1,37 @@ +use thiserror::Error; + +/// Custom error types for the ssg-metadata library +#[derive(Error, Debug)] +pub enum MetadataError { + /// Error occurred while extracting metadata + #[error("Failed to extract metadata: {0}")] + ExtractionError(String), + + /// Error occurred while processing metadata + #[error("Failed to process metadata: {0}")] + ProcessingError(String), + + /// Error occurred due to missing required field + #[error("Missing required metadata field: {0}")] + MissingFieldError(String), + + /// Error occurred while parsing date + #[error("Failed to parse date: {0}")] + DateParseError(String), + + /// I/O error + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// YAML parsing error + #[error("YAML parsing error: {0}")] + YamlError(#[from] yaml_rust2::ScanError), + + /// JSON parsing error + #[error("JSON parsing error: {0}")] + JsonError(#[from] serde_json::Error), + + /// TOML parsing error + #[error("TOML parsing error: {0}")] + TomlError(#[from] toml::de::Error), +} diff --git a/ssg-metadata/src/escape.rs b/ssg-metadata/src/escape.rs deleted file mode 100644 index 84ec355b..00000000 --- a/ssg-metadata/src/escape.rs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 OR MIT -// Contains macros related to directory operations. - -/// Escapes special HTML characters in a string. -/// -/// # Examples -/// -/// ``` -/// use ssg_metadata::escape::escape_html_entities; -/// -/// let input = "Hello, !"; -/// let expected = "Hello, <world>!"; -/// -/// assert_eq!(escape_html_entities(input), expected); -/// ``` -pub fn escape_html_entities(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('\"', """) - .replace('\'', "'") - .replace('/', "/") -} diff --git a/ssg-metadata/src/extractor.rs b/ssg-metadata/src/extractor.rs deleted file mode 100644 index d4e0bbcb..00000000 --- a/ssg-metadata/src/extractor.rs +++ /dev/null @@ -1,76 +0,0 @@ -use anyhow::Result; -use regex::Regex; -use serde_json::Value as JsonValue; -use std::collections::HashMap; -use toml::Value as TomlValue; -use yaml_rust2::YamlLoader; - -use crate::models::Metadata; - -/// Extracts metadata from the content string. -pub fn extract_metadata(content: &str) -> Result { - if let Some(yaml_metadata) = extract_yaml_metadata(content) { - Ok(yaml_metadata) - } else if let Some(toml_metadata) = extract_toml_metadata(content) { - Ok(toml_metadata) - } else if let Some(json_metadata) = extract_json_metadata(content) { - Ok(json_metadata) - } else { - Ok(Metadata::default()) - } -} - -fn extract_yaml_metadata(content: &str) -> Option { - let re = Regex::new(r"(?s)^---\s*\n(.*?)\n---").ok()?; - let captures = re.captures(content)?; - let yaml_str = captures.get(1)?.as_str(); - - let docs = YamlLoader::load_from_str(yaml_str).ok()?; - let yaml = docs.into_iter().next()?; - - let metadata: HashMap = yaml - .as_hash()? - .iter() - .filter_map(|(k, v)| { - Some((k.as_str()?.to_string(), v.as_str()?.to_string())) - }) - .collect(); - - Some(Metadata::new(metadata)) -} - -fn extract_toml_metadata(content: &str) -> Option { - let re = Regex::new(r"(?s)^\+\+\+\s*\n(.*?)\n\+\+\+").ok()?; - let captures = re.captures(content)?; - let toml_str = captures.get(1)?.as_str(); - - let toml_value: TomlValue = toml::from_str(toml_str).ok()?; - let toml_table = toml_value.as_table()?; - - let metadata: HashMap = toml_table - .iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| (k.clone(), s.to_string())) - }) - .collect(); - - Some(Metadata::new(metadata)) -} - -fn extract_json_metadata(content: &str) -> Option { - let re = Regex::new(r"(?s)^\{(.*?)\}").ok()?; - let captures = re.captures(content)?; - let json_str = format!("{{{}}}", captures.get(1)?.as_str()); - - let json_value: JsonValue = serde_json::from_str(&json_str).ok()?; - let json_object = json_value.as_object()?; - - let metadata: HashMap = json_object - .iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| (k.clone(), s.to_string())) - }) - .collect(); - - Some(Metadata::new(metadata)) -} diff --git a/ssg-metadata/src/keywords.rs b/ssg-metadata/src/keywords.rs deleted file mode 100644 index 45524611..00000000 --- a/ssg-metadata/src/keywords.rs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use std::collections::HashMap; - -/// Extracts keywords from the metadata and returns them as a vector of strings. -/// -/// This function takes a reference to a HashMap containing metadata key-value pairs. -/// It looks for the "keywords" key in the metadata and extracts the keywords from its value. -/// Keywords are expected to be comma-separated. The extracted keywords are trimmed of any -/// leading or trailing whitespace, and returned as a vector of strings. -/// -/// # Arguments -/// -/// * `metadata` - A reference to a HashMap containing metadata. -/// -/// # Returns -/// -/// A vector of strings representing the extracted keywords. -/// -pub fn extract_keywords( - metadata: &HashMap, -) -> Vec { - // Check if the "keywords" key exists in the metadata. - // If it exists, split the keywords using a comma and process each keyword. - // If it doesn't exist, return an empty vector as the default value. - metadata - .get("keywords") - .map(|keywords| { - // Split the keywords using commas and process each keyword. - keywords - .split(',') - .map(|kw| kw.trim().to_string()) // Trim whitespace from each keyword. - .collect::>() // Collect the processed keywords into a vector. - }) - .unwrap_or_default() // Return an empty vector if "keywords" is not found. -} diff --git a/ssg-metadata/src/lib.rs b/ssg-metadata/src/lib.rs index 8ba97c00..5e377c53 100644 --- a/ssg-metadata/src/lib.rs +++ b/ssg-metadata/src/lib.rs @@ -9,40 +9,31 @@ //! - Process and validate metadata //! - Generate keywords based on metadata //! - Create meta tags for HTML documents -//! -//! ## Main Components -//! -//! - `extract_metadata`: Extracts metadata from content -//! - `process_metadata`: Processes and validates extracted metadata -//! - `extract_and_prepare_metadata`: Combines extraction, processing, and meta tag generation - -use anyhow::{Context, Result}; -use std::collections::HashMap; - -/// Functions for escaping special characters in metadata values -pub mod escape; +//! - Asynchronously extract metadata from files -/// Functions for extracting front matter from content files -pub mod extractor; - -/// Functions for processing and validating metadata -pub mod processor; - -/// Data structures for representing metadata and related information -pub mod models; - -/// Functions for generating keywords from metadata -pub mod keywords; - -/// Functions for generating HTML meta tags +/// The `error` module contains error types for metadata processing. +pub mod error; +/// The `metadata` module contains functions for extracting and processing metadata. +pub mod metadata; +/// The `metatags` module contains functions for generating meta tags. pub mod metatags; +/// The `utils` module contains utility functions for metadata processing. +pub mod utils; + +pub use error::MetadataError; +pub use metadata::{extract_metadata, process_metadata, Metadata}; +pub use metatags::{generate_metatags, MetaTagGroups}; +pub use utils::{ + async_extract_metadata_from_file, escape_html_entities, +}; -/// Macros for common metadata operations -pub mod macros; +use std::collections::HashMap; -pub use extractor::extract_metadata; -use models::MetaTagGroups; -pub use processor::process_metadata; +/// Type aliases for improving readability and reducing complexity +type MetadataMap = HashMap; +type Keywords = Vec; +type MetadataResult = + Result<(MetadataMap, Keywords, MetaTagGroups), MetadataError>; /// Extracts metadata from the content, generates keywords based on the metadata, /// and prepares meta tag groups. @@ -65,7 +56,7 @@ pub use processor::process_metadata; /// /// # Errors /// -/// This function will return an error if metadata extraction fails. +/// This function will return a `MetadataError` if metadata extraction or processing fails. /// /// # Example /// @@ -82,14 +73,31 @@ pub use processor::process_metadata; /// let result = extract_and_prepare_metadata(content); /// assert!(result.is_ok()); /// ``` -pub fn extract_and_prepare_metadata( - content: &str, -) -> Result<(HashMap, Vec, MetaTagGroups)> { - let metadata = extract_metadata(content) - .context("Failed to extract metadata")?; +pub fn extract_and_prepare_metadata(content: &str) -> MetadataResult { + let metadata = extract_metadata(content)?; let metadata_map = metadata.into_inner(); - let keywords = keywords::extract_keywords(&metadata_map); - let all_meta_tags = metatags::generate_all_meta_tags(&metadata_map); + let keywords = extract_keywords(&metadata_map); + let all_meta_tags = generate_metatags(&metadata_map); Ok((metadata_map, keywords, all_meta_tags)) } + +/// Extracts keywords from the metadata. +/// +/// This function looks for a "keywords" key in the metadata and splits its value into a vector of strings. +/// +/// # Arguments +/// +/// * `metadata` - A reference to a HashMap containing the metadata. +/// +/// # Returns +/// +/// A vector of strings representing the keywords. +pub fn extract_keywords( + metadata: &HashMap, +) -> Vec { + metadata + .get("keywords") + .map(|k| k.split(',').map(|s| s.trim().to_string()).collect()) + .unwrap_or_default() +} diff --git a/ssg-metadata/src/macros/mod.rs b/ssg-metadata/src/macros/mod.rs deleted file mode 100644 index 93ee5eae..00000000 --- a/ssg-metadata/src/macros/mod.rs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 OR MIT -// Contains macros related to directory operations. - -//! Contains macros related to HTML generation. -//! -//! This module provides macros for generating HTML meta tags, writing XML elements, and generating HTML meta tags -//! from metadata HashMaps or field-value pairs. -//! -//! # Meta Tags Generation -//! -//! Meta tags are essential for defining metadata within HTML documents, such as page descriptions, keywords, and other -//! information relevant to search engines and web crawlers. The `macro_generate_metatags` macro allows generating -//! meta tags dynamically based on provided key-value pairs. -//! -//! # XML Element Writing -//! -//! XML elements are fundamental components of XML documents, including HTML, RSS feeds, and other structured data formats. -//! The `macro_write_element` macro facilitates writing XML elements to a specified writer, enabling the dynamic generation -//! of XML content in Rust applications. -//! -//! # Meta Tags Generation from Metadata -//! -//! In addition to generating meta tags from key-value pairs, this module provides macros for generating HTML meta tags -//! directly from metadata HashMaps or field-value pairs. These macros offer flexibility in constructing HTML meta tags -//! based on existing metadata structures or data models. -//! -//! # Example -//! -//! ``` -//! use ssg_metadata::macro_generate_metatags; -//! use ssg_metadata::escape::escape_html_entities; -//! -//! let metatags = macro_generate_metatags!("description", "This is a description", "keywords", "rust,macros,metatags"); -//! ``` - -/// Generates meta tags based on provided key-value pairs. -/// -/// ## Usage -/// -/// ```rust -/// use ssg_metadata::macro_generate_metatags; -/// use ssg_metadata::escape::escape_html_entities; -/// -/// let metatags = macro_generate_metatags!("description", "This is a description", "keywords", "rust,macros,metatags"); -/// ``` -/// -/// ## Arguments -/// -/// * `($key:literal, $value:expr),*` - Pairs of a literal key and an expression value, each specified as `literal, expr`. -/// -/// ## Behaviour -/// -/// This macro generates meta tags using the provided keys and values. It takes pairs of literal keys and expression values and constructs HTML meta tags accordingly. -/// -/// The pairs of keys and values are specified as `literal, expr` and separated by commas. For example, `macro_generate_metatags!("description", "This is a description", "keywords", "rust,macros,metatags")` will generate meta tags with the keys `description` and `keywords` and their corresponding values. -/// -/// The macro internally creates a slice of tuples of the keys and values and passes it to the `generate_metatags` function. The function should return a string that represents the generated meta tags. -/// -// #[macro_export] -// macro_rules! macro_generate_metatags { -// ($($key:literal, $value:expr),* $(,)?) => { -// $crate::metatags::generate_metatags(&[ $(($key.to_owned(), $value.to_string())),* ]) -// }; -// } -#[macro_export] -macro_rules! macro_generate_metatags { - ($($key:literal, $value:expr),* $(,)?) => { - $crate::metatags::generate_metatags(&[ $(($key.to_owned(), escape_html_entities($value))),* ]) - }; -} - -/// Writes an XML element to the specified writer. -/// -/// ## Usage -/// -/// ```rust -/// use ssg_metadata::macro_write_element; -/// use std::io::Write; -/// use quick_xml::Writer; -/// -/// let mut writer = Writer::new(Vec::new()); -/// macro_write_element!(&mut writer, "title", "Hello, world!"); -/// ``` -/// -/// ## Arguments -/// -/// * `$writer:expr` - The writer instance to write the XML element to. -/// * `$name:expr` - The name of the XML element. -/// * `$value:expr` - The value of the XML element. -/// -/// ## Behaviour -/// -/// This macro writes an XML element with the specified name and value to the provided writer instance. It is primarily useful for generating XML documents, such as HTML or RSS feeds, dynamically. -/// -#[macro_export] -macro_rules! macro_write_element { - ($writer:expr, $name:expr, $value:expr) => {{ - use quick_xml::events::{ - BytesEnd, BytesStart, BytesText, Event, - }; - use std::borrow::Cow; - use std::error::Error; - - let result: Result<(), Box> = (|| -> Result<(), Box> { - if !$value.is_empty() { - let element_start = BytesStart::new($name); - $writer.write_event(Event::Start(element_start.clone()))?; - $writer.write_event(Event::Text(BytesText::from_escaped($value)))?; - - let element_end = BytesEnd::new::>( - std::str::from_utf8(element_start.name().as_ref()).unwrap().to_string().into(), - ); - - $writer.write_event(Event::End(element_end))?; - } - Ok(()) - })(); - - result - }}; -} - -/// Generates HTML meta tags based on a list of tag names and a metadata HashMap. -/// -/// ## Usage -/// -/// ``` -/// use ssg_metadata::macro_generate_tags_from_list; -/// use ssg_metadata::metatags::load_metatags; -/// use std::collections::HashMap; -/// -/// // Create a new metadata hashmap -/// let mut metadata = HashMap::new(); -/// -/// // Insert String values into the hashmap -/// metadata.insert(String::from("description"), String::from("This is a description")); -/// metadata.insert(String::from("keywords"), String::from("rust,macros,metatags")); -/// -/// // Define tag names -/// let tag_names = &["description", "keywords"]; -/// -/// // Call the macro with correct hashmap types -/// let html_meta_tags = macro_generate_tags_from_list!(tag_names, &metadata); -/// println!("{}", html_meta_tags); -/// ``` -/// -/// ## Arguments -/// -/// * `$tag_names:expr` - An array slice containing the names of the tags to generate. -/// * `$metadata:expr` - The metadata HashMap containing the values for the tags. -/// -/// ## Returns -/// -/// Returns a string containing the HTML meta tags generated from the metadata HashMap. -/// -#[macro_export] -macro_rules! macro_generate_tags_from_list { - ($tag_names:expr, $metadata:expr) => { - load_metatags($tag_names, $metadata) - }; -} - -/// Generates HTML meta tags based on field-value pairs from a metadata HashMap. -/// -/// ## Usage -/// -/// ``` -/// use ssg_metadata::macro_generate_tags_from_fields; -/// use ssg_metadata::metatags::generate_custom_meta_tags; -/// use std::collections::HashMap; -/// -/// // Create a new metadata hashmap -/// let mut metadata = HashMap::new(); -/// -/// -/// // Insert String values into the hashmap -/// metadata.insert(String::from("description"), String::from("This is a description")); -/// metadata.insert(String::from("keywords"), String::from("rust,macros,metatags")); -/// -/// // Call the macro with correct hashmap types -/// let html_meta_tags = macro_generate_tags_from_fields!(tag_names,metadata, "description" => description, "keywords" => keywords); -/// println!("{}", html_meta_tags); -/// ``` -/// -/// ## Arguments -/// -/// * `$name:ident` - The name of the metadata HashMap. -/// * `$metadata:expr` - The metadata HashMap containing the field-value pairs. -/// * `$( $tag:literal => $field:ident ),*` - Pairs of literal tag names and metadata field names. -/// -/// ## Returns -/// -/// Returns a string containing the HTML meta tags generated from the metadata HashMap. -/// -#[macro_export] -macro_rules! macro_generate_tags_from_fields { - ($name:ident, $metadata:expr, $($tag:literal => $field:ident),*) => { - { - let tag_mapping: Vec<(String, Option)> = vec![ - $( - ($tag.to_string(), $metadata.get(stringify!($field)).cloned()), - )* - ]; - generate_custom_meta_tags(&tag_mapping) - } - }; -} diff --git a/ssg-metadata/src/metadata.rs b/ssg-metadata/src/metadata.rs new file mode 100644 index 00000000..c879e6e9 --- /dev/null +++ b/ssg-metadata/src/metadata.rs @@ -0,0 +1,363 @@ +use crate::error::MetadataError; +use dtt::datetime::DateTime; +use regex::Regex; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use toml::Value as TomlValue; +use yaml_rust2::YamlLoader; + +/// Represents metadata for a page or content item. +#[derive(Debug, Default, Clone)] +pub struct Metadata { + inner: HashMap, +} + +impl Metadata { + /// Creates a new `Metadata` instance with the given data. + pub fn new(data: HashMap) -> Self { + Metadata { inner: data } + } + + /// Retrieves the value associated with the given key. + pub fn get(&self, key: &str) -> Option<&String> { + self.inner.get(key) + } + + /// Inserts a key-value pair into the metadata. + pub fn insert( + &mut self, + key: String, + value: String, + ) -> Option { + self.inner.insert(key, value) + } + + /// Checks if the metadata contains the given key. + pub fn contains_key(&self, key: &str) -> bool { + self.inner.contains_key(key) + } + + /// Consumes the `Metadata` instance and returns the inner `HashMap`. + pub fn into_inner(self) -> HashMap { + self.inner + } +} + +/// Extracts metadata from the content string. +pub fn extract_metadata( + content: &str, +) -> Result { + println!("Extracting metadata from content:\n{}", content); // Debugging output + + if let Some(yaml_metadata) = extract_yaml_metadata(content) { + println!("Extracted YAML metadata: {:?}", yaml_metadata); // Debugging output + Ok(yaml_metadata) + } else if let Some(toml_metadata) = extract_toml_metadata(content) { + println!("Extracted TOML metadata: {:?}", toml_metadata); // Debugging output + Ok(toml_metadata) + } else if let Some(json_metadata) = extract_json_metadata(content) { + println!("Extracted JSON metadata: {:?}", json_metadata); // Debugging output + Ok(json_metadata) + } else { + println!("No valid front matter found."); // Debugging output + Err(MetadataError::ExtractionError( + "No valid front matter found.".to_string(), + )) + } +} + +fn extract_yaml_metadata(content: &str) -> Option { + // More flexible regex for YAML front matter + let re = Regex::new(r"(?s)^\s*---\s*\n(.*?)\n\s*---\s*").ok()?; + let captures = re.captures(content)?; + + let yaml_str = captures.get(1)?.as_str().trim(); + println!("Captured YAML content: {:?}", yaml_str); // Debugging output + + let docs = YamlLoader::load_from_str(yaml_str).ok()?; + + if docs.is_empty() { + println!("Failed to parse YAML content."); // Debugging output + return None; + } + + let yaml = docs.into_iter().next()?; + + let metadata: HashMap = yaml + .as_hash()? + .iter() + .filter_map(|(k, v)| { + Some((k.as_str()?.to_string(), v.as_str()?.to_string())) + }) + .collect(); + + println!("Extracted YAML metadata map: {:?}", metadata); // Debugging output + + Some(Metadata::new(metadata)) +} + +fn extract_toml_metadata(content: &str) -> Option { + let re = Regex::new(r"(?s)^\s*\+\+\+\s*(.*?)\s*\+\+\+").ok()?; + let captures = re.captures(content)?; + let toml_str = captures.get(1)?.as_str().trim(); // Trim to remove unnecessary spaces + + let toml_value: TomlValue = toml::from_str(toml_str).ok()?; + let toml_table = toml_value.as_table()?; + + let metadata: HashMap = toml_table + .iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| (k.clone(), s.to_string())) + }) + .collect(); + + Some(Metadata::new(metadata)) +} + +fn extract_json_metadata(content: &str) -> Option { + let re = Regex::new(r"(?s)^\s*\{\s*(.*?)\s*\}").ok()?; + let captures = re.captures(content)?; + let json_str = format!("{{{}}}", captures.get(1)?.as_str().trim()); + + let json_value: JsonValue = serde_json::from_str(&json_str).ok()?; + let json_object = json_value.as_object()?; + + let metadata: HashMap = json_object + .iter() + .filter_map(|(k, v)| { + v.as_str().map(|s| (k.clone(), s.to_string())) + }) + .collect(); + + Some(Metadata::new(metadata)) +} + +/// Processes the extracted metadata. +pub fn process_metadata( + metadata: &Metadata, +) -> Result { + let mut processed = metadata.clone(); + + // Convert dates to a standard format + if let Some(date) = processed.get("date").cloned() { + let standardized_date = standardize_date(&date)?; + processed.insert("date".to_string(), standardized_date); + } + + // Ensure required fields are present + ensure_required_fields(&processed)?; + + // Generate derived fields + generate_derived_fields(&mut processed); + + Ok(processed) +} + +fn standardize_date(date: &str) -> Result { + println!("🦀 Standardizing Date: {} 🦀", date); + + // Handle edge cases with empty or too-short dates + if date.trim().is_empty() { + return Err(MetadataError::DateParseError( + "Date string is empty.".to_string(), + )); + } + + if date.len() < 8 { + return Err(MetadataError::DateParseError( + "Date string is too short.".to_string(), + )); + } + + // Check if the date is in the DD/MM/YYYY format and reformat to YYYY-MM-DD + let date = if date.contains('/') && date.len() == 10 { + let parts: Vec<&str> = date.split('/').collect(); + if parts.len() == 3 { + if parts[0].len() == 2 + && parts[1].len() == 2 + && parts[2].len() == 4 + { + format!("{}-{}-{}", parts[2], parts[1], parts[0]) // Reformat to YYYY-MM-DD + } else { + return Err(MetadataError::DateParseError( + "Invalid DD/MM/YYYY date format.".to_string(), + )); + } + } else { + return Err(MetadataError::DateParseError( + "Date string could not be split into three parts." + .to_string(), + )); + } + } else { + date.to_string() + }; + + // Attempt to parse the date in different formats using DateTime methods + let parsed_date = DateTime::parse(&date) + .or_else(|_| { + println!("Failed with default parse, trying custom format YYYY-MM-DD."); + DateTime::parse_custom_format(&date, "[year]-[month]-[day]") + }) + .or_else(|_| { + println!("Failed with YYYY-MM-DD, trying MM/DD/YYYY."); + DateTime::parse_custom_format(&date, "[month]/[day]/[year]") // Handle MM/DD/YYYY + }) + .map_err(|e| MetadataError::DateParseError(format!("Failed to parse date: {}", e)))?; + + println!("Parsed date: ✅ {:?}", parsed_date); + + // Convert Month enum to numeric value + let month_number = match parsed_date.month() { + time::Month::January => 1, + time::Month::February => 2, + time::Month::March => 3, + time::Month::April => 4, + time::Month::May => 5, + time::Month::June => 6, + time::Month::July => 7, + time::Month::August => 8, + time::Month::September => 9, + time::Month::October => 10, + time::Month::November => 11, + time::Month::December => 12, + }; + + // Format the date to the standardized YYYY-MM-DD format + let formatted_date = format!( + "{:04}-{:02}-{:02}", + parsed_date.year(), + month_number, + parsed_date.day() + ); + + println!("Formatted date: ✅ {}", formatted_date); + + Ok(formatted_date) +} + +fn ensure_required_fields( + metadata: &Metadata, +) -> Result<(), MetadataError> { + let required_fields = vec!["title", "date"]; + + for field in required_fields { + if !metadata.contains_key(field) { + return Err(MetadataError::MissingFieldError( + field.to_string(), + )); + } + } + + Ok(()) +} + +fn generate_derived_fields(metadata: &mut Metadata) { + // Generate a URL slug from the title if not present + if !metadata.contains_key("slug") { + if let Some(title) = metadata.get("title") { + let slug = generate_slug(title); + metadata.insert("slug".to_string(), slug); + } + } +} + +fn generate_slug(title: &str) -> String { + title.to_lowercase().replace(' ', "-") +} + +#[cfg(test)] +mod tests { + use super::*; + use dtt::dtt_parse; + + #[test] + fn test_standardize_date() { + let test_cases = vec![ + ("2023-05-20T15:30:00Z", "2023-05-20"), + ("2023-05-20", "2023-05-20"), + ("20/05/2023", "2023-05-20"), // European format DD/MM/YYYY + ]; + + for (input, expected) in test_cases { + let result = standardize_date(input); + assert!(result.is_ok(), "Failed for input: {}", input); + assert_eq!(result.unwrap(), expected); + } + } + + #[test] + #[should_panic(expected = "Invalid date format")] + fn test_standardize_date_fail() { + let date_string = "2023-02-29"; // Invalid date for non-leap year + let _ = standardize_date(date_string).unwrap(); // Should panic + } + + #[test] + fn test_date_format() { + let dt = dtt_parse!("2023-01-01T12:00:00+00:00").unwrap(); + + // Manually extract year, month, and day from dt + let year = dt.year(); // Assuming dt has a year() method + let month = dt.month() as u32; // Assuming dt has a month() method and it returns a numeric value + let day = dt.day(); // Assuming dt has a day() method + + // Format the date using format! macro + let formatted = format!("{:04}-{:02}-{:02}", year, month, day); + + // Verify that the formatted date matches the expected value + assert_eq!(formatted, "2023-01-01"); + } + + #[test] + fn test_generate_slug() { + assert_eq!(generate_slug("Hello World"), "hello-world"); + assert_eq!(generate_slug("Test 123"), "test-123"); + assert_eq!(generate_slug(" Spaces "), "--spaces--"); + } + + #[test] + fn test_process_metadata() { + let mut metadata = Metadata::new(HashMap::new()); + metadata.insert("title".to_string(), "Test Title".to_string()); + metadata.insert( + "date".to_string(), + "2023-05-20T15:30:00Z".to_string(), + ); + + let processed = process_metadata(&metadata).unwrap(); + assert_eq!(processed.get("title").unwrap(), "Test Title"); + assert_eq!(processed.get("date").unwrap(), "2023-05-20"); + assert_eq!(processed.get("slug").unwrap(), "test-title"); + } + + #[test] + fn test_extract_metadata() { + let yaml_content = r#"--- +title: YAML Test +date: 2023-05-20 +--- +Content here"#; + + let toml_content = r#"+++ +title = "TOML Test" +date = "2023-05-20" ++++ +Content here"#; + + let json_content = r#"{ +"title": "JSON Test", +"date": "2023-05-20" +} +Content here"#; + + let yaml_metadata = extract_metadata(yaml_content).unwrap(); + assert_eq!(yaml_metadata.get("title").unwrap(), "YAML Test"); + + let toml_metadata = extract_metadata(toml_content).unwrap(); + assert_eq!(toml_metadata.get("title").unwrap(), "TOML Test"); + + let json_metadata = extract_metadata(json_content).unwrap(); + assert_eq!(json_metadata.get("title").unwrap(), "JSON Test"); + } +} diff --git a/ssg-metadata/src/metatags.rs b/ssg-metadata/src/metatags.rs index 12a5334f..bd7a20a0 100644 --- a/ssg-metadata/src/metatags.rs +++ b/ssg-metadata/src/metatags.rs @@ -1,244 +1,202 @@ -// Copyright © 2024 Shokunin Static Site Generator. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 OR MIT +use std::{collections::HashMap, fmt}; -use crate::macro_generate_tags_from_fields; -use crate::models::{MetaTag, MetaTagGroups}; -use std::collections::HashMap; +/// Holds collections of meta tags for different platforms and categories. +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone)] +pub struct MetaTagGroups { + /// The `apple` meta tags. + pub apple: String, + /// The primary meta tags. + pub primary: String, + /// The `og` meta tags. + pub og: String, + /// The `ms` meta tags. + pub ms: String, + /// The `twitter` meta tags. + pub twitter: String, +} -// Type alias for better readability -type MetaDataMap = HashMap; +impl MetaTagGroups { + /// Adds a custom meta tag to the appropriate group. + /// + /// # Arguments + /// + /// * `name` - The name of the meta tag. + /// * `content` - The content of the meta tag. + pub fn add_custom_tag(&mut self, name: &str, content: &str) { + if name.starts_with("apple") { + self.apple.push_str(&format_meta_tag(name, content)); + } else if name.starts_with("og") { + self.og.push_str(&format_meta_tag(name, content)); + } else if name.starts_with("ms") { + self.ms.push_str(&format_meta_tag(name, content)); + } else if name.starts_with("twitter") { + self.twitter.push_str(&format_meta_tag(name, content)); + } else { + self.primary.push_str(&format_meta_tag(name, content)); + } + } +} -/// Generates HTML meta tags based on custom key-value mappings. -/// -/// # Arguments -/// * `mapping` - A slice of tuples, where each tuple contains a `String` key and an `Option` value. -/// -/// # Returns -/// A `String` containing the HTML code for the meta tags. -pub fn generate_custom_meta_tags( - mapping: &[(String, Option)], -) -> String { - let filtered_mapping: Vec<(String, String)> = mapping - .iter() - .filter_map(|(key, value)| { - value - .as_ref() - .map(|val| (key.clone(), val.clone())) - .filter(|(_, val)| !val.is_empty()) - }) - .collect(); - generate_metatags(&filtered_mapping) +/// Implement `Display` for `MetaTagGroups`. +impl fmt::Display for MetaTagGroups { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}\n{}\n{}\n{}\n{}", + self.apple, self.primary, self.og, self.ms, self.twitter + ) + } } -/// Generates HTML meta tags based on the provided key-value pairs. +/// Generates HTML meta tags based on the provided metadata. +/// +/// This function takes metadata from a `HashMap` and generates meta tags for various platforms (e.g., Apple, Open Graph, Twitter). /// /// # Arguments -/// * `meta` - A slice of key-value pairs represented as tuples of `String` objects. +/// +/// * `metadata` - A reference to a `HashMap` containing the metadata. /// /// # Returns -/// A `String` containing the HTML code for the meta tags. -pub fn generate_metatags(meta: &[(String, String)]) -> String { - meta.iter() - .map(|(key, value)| format_meta_tag(key, value.trim())) - .collect::>() - .join("\n") +/// +/// A `MetaTagGroups` structure with meta tags grouped by platform. +pub fn generate_metatags( + metadata: &HashMap, +) -> MetaTagGroups { + MetaTagGroups { + apple: generate_apple_meta_tags(metadata), + primary: generate_primary_meta_tags(metadata), + og: generate_og_meta_tags(metadata), + ms: generate_ms_meta_tags(metadata), + twitter: generate_twitter_meta_tags(metadata), + } } -/// Generates HTML meta tags based on a list of tag names and a metadata HashMap. -/// -/// # Arguments -/// * `tag_names` - A slice of tag names as `&str`. -/// * `metadata` - A reference to a `MetaDataMap` containing metadata key-value pairs. -/// -/// # Returns -/// A `String` containing the HTML code for the meta tags. -pub fn load_metatags( - tag_names: &[&str], - metadata: &MetaDataMap, +/// Generates meta tags for Apple devices. +fn generate_apple_meta_tags( + metadata: &HashMap, ) -> String { - let mut result = String::new(); - for &name in tag_names { - let value = - metadata.get(name).cloned().unwrap_or_else(String::new); - result.push_str( - &MetaTag::new(name.to_string(), value).generate(), - ); - } - result + const APPLE_TAGS: [&str; 3] = [ + "apple-mobile-web-app-capable", + "apple-mobile-web-app-status-bar-style", + "apple-mobile-web-app-title", + ]; + generate_tags(metadata, &APPLE_TAGS) } -/// Utility function to format a single meta tag into its HTML representation. -/// -/// # Arguments -/// * `key` - The name attribute of the meta tag. -/// * `value` - The content attribute of the meta tag. -/// -/// # Returns -/// A `String` containing the HTML representation of the meta tag. -pub fn format_meta_tag(key: &str, value: &str) -> String { - // Sanitize the value by replacing newline characters with spaces - let sanitized_value = value.replace('\n', " "); - format!("", key, &sanitized_value) +/// Generates primary meta tags like `author`, `description`, and `keywords`. +fn generate_primary_meta_tags( + metadata: &HashMap, +) -> String { + const PRIMARY_TAGS: [&str; 4] = + ["author", "description", "keywords", "viewport"]; + generate_tags(metadata, &PRIMARY_TAGS) } -/// Generates HTML meta tags for Apple-specific settings. -/// -/// # Arguments -/// * `metadata` - A reference to a `HashMap` containing metadata key-value pairs. -/// -/// # Returns -/// A `String` containing the HTML code for the meta tags. -/// -pub fn generate_apple_meta_tags(metadata: &MetaDataMap) -> String { - macro_generate_tags_from_fields!( - tag_names, - metadata, - "apple_mobile_web_app_orientations" => apple_mobile_web_app_orientations, - "apple_touch_icon_sizes" => apple_touch_icon_sizes, - "apple-mobile-web-app-capable" => apple_mobile_web_app_capable, - "apple-mobile-web-app-status-bar-inset" => apple_mobile_web_app_status_bar_inset, - "apple-mobile-web-app-status-bar-style" => apple_mobile_web_app_status_bar_style, - "apple-mobile-web-app-title" => apple_mobile_web_app_title, - "apple-touch-fullscreen" => apple_touch_fullscreen - ) +/// Generates Open Graph (`og`) meta tags for social media. +fn generate_og_meta_tags(metadata: &HashMap) -> String { + const OG_TAGS: [&str; 5] = [ + "og:title", + "og:description", + "og:image", + "og:url", + "og:type", + ]; + generate_tags(metadata, &OG_TAGS) } -/// Generates HTML meta tags for primary settings like author, description, etc. -/// -/// # Arguments -/// * `metadata` - A reference to a `HashMap` containing metadata key-value pairs. -/// -/// # Returns -/// A `String` containing the HTML code for the meta tags. -/// -pub fn generate_primary_meta_tags(metadata: &MetaDataMap) -> String { - macro_generate_tags_from_fields!( - tag_names, - metadata, - "author" => author, - "description" => description, - "format-detection" => format_detection, - "generator" => generator, - "keywords" => keywords, - "language" => language, - "permalink" => permalink, - "rating" => rating, - "referrer" => referrer, - "revisit-after" => revisit_after, - "robots" => robots, - "theme-color" => theme_color, - "title" => title, - "viewport" => viewport - ) +/// Generates Microsoft-specific meta tags. +fn generate_ms_meta_tags(metadata: &HashMap) -> String { + const MS_TAGS: [&str; 2] = + ["msapplication-TileColor", "msapplication-TileImage"]; + generate_tags(metadata, &MS_TAGS) } -/// Generates HTML meta tags for Open Graph settings, primarily for social media. -/// -/// This function expects the `metadata` HashMap to contain keys such as: -/// -/// - "og:description": The description of the content. -/// - "og:image": The URL of the image to use. -/// - "og:image:alt": The alt text for the image. -/// - "og:image:height": The height of the image. -/// - "og:image:width": The width of the image. -/// - "og:locale": The locale of the content. -/// - "og:site_name": The name of the site. -/// - "og:title": The title of the content. -/// - "og:type": The type of content. -/// - "og:url": The URL of the content. -/// -/// # Arguments -/// * `metadata` - A reference to a `MetaDataMap` containing metadata key-value pairs. -/// -/// # Returns -/// A `String` containing the HTML code for the meta tags. -/// -pub fn generate_og_meta_tags(metadata: &MetaDataMap) -> String { - macro_generate_tags_from_fields!( - tag_names, - metadata, - "og:description" => description, - "og:image" => image, - "og:image:alt" => image_alt, - "og:image:height" => image_height, - "og:image:width" => image_width, - "og:locale" => locale, - "og:site_name" => site_name, - "og:title" => title, - "og:type" => type, - "og:url" => url - ) +/// Generates Twitter meta tags for embedding rich media in tweets. +fn generate_twitter_meta_tags( + metadata: &HashMap, +) -> String { + const TWITTER_TAGS: [&str; 5] = [ + "twitter:card", + "twitter:site", + "twitter:title", + "twitter:description", + "twitter:image", + ]; + generate_tags(metadata, &TWITTER_TAGS) } -/// Generates HTML meta tags for Microsoft-specific settings. +/// Generates meta tags based on the provided list of tag names. /// /// # Arguments -/// * `metadata` - A reference to a `HashMap` containing metadata key-value pairs. +/// +/// * `metadata` - A reference to a `HashMap` containing the metadata. +/// * `tags` - A reference to an array of tag names. /// /// # Returns -/// A `String` containing the HTML code for the meta tags. /// -pub fn generate_ms_meta_tags(metadata: &MetaDataMap) -> String { - macro_generate_tags_from_fields!( - tag_names, - metadata, - "msapplication-navbutton-color" => msapplication_navbutton_color - ) +/// A string containing the generated meta tags. +fn generate_tags( + metadata: &HashMap, + tags: &[&str], +) -> String { + tags.iter() + .filter_map(|&tag| { + metadata.get(tag).map(|value| format_meta_tag(tag, value)) + }) + .collect::>() + .join("\n") } -/// Generates HTML meta tags for Twitter-specific settings. -/// -/// This function expects the `metadata` HashMap to contain keys such as: -/// - "twitter:card": The type of Twitter card to use. -/// - "twitter:creator": The Twitter handle of the content creator. -/// - "twitter:description": The description of the content. -/// - "twitter:image": The URL of the image to use. -/// - "twitter:image:alt": The alt text for the image. -/// - "twitter:image:height": The height of the image. -/// - "twitter:image:width": The width of the image. -/// - "twitter:site": The Twitter handle of the site. -/// - "twitter:title": The title of the content. -/// - "twitter:url": The URL of the content. +/// Formats a single meta tag. /// /// # Arguments -/// * `metadata` - A reference to a `MetaDataMap` containing metadata key-value pairs. +/// +/// * `name` - The name of the meta tag (e.g., `author`, `description`). +/// * `content` - The content of the meta tag. /// /// # Returns -/// A `String` containing the HTML code for the meta tags. /// -pub fn generate_twitter_meta_tags(metadata: &MetaDataMap) -> String { - macro_generate_tags_from_fields!( - tag_names, - metadata, - "twitter:card" => twitter_card, - "twitter:creator" => twitter_creator, - "twitter:description" => twitter_description, - "twitter:image" => twitter_image, - "twitter:image:alt" => twitter_image_alt, - "twitter:image:height" => twitter_image_height, - "twitter:image:width" => twitter_image_width, - "twitter:site" => twitter_site, - "twitter:title" => twitter_title, - "twitter:url" => twitter_url +/// A formatted meta tag string. +fn format_meta_tag(name: &str, content: &str) -> String { + format!( + "", + name, + content.replace('"', """) ) } -/// Generates meta tags for the given metadata. -/// -/// # Arguments -/// -/// * `metadata` - The metadata extracted from the file. -/// -/// # Returns -/// -/// Returns a tuple containing meta tags for Apple devices, primary information, Open Graph, Microsoft, and Twitter. -/// -pub fn generate_all_meta_tags(metadata: &MetaDataMap) -> MetaTagGroups { - MetaTagGroups { - apple: generate_apple_meta_tags(metadata), - primary: generate_primary_meta_tags(metadata), - og: generate_og_meta_tags(metadata), - ms: generate_ms_meta_tags(metadata), - twitter: generate_twitter_meta_tags(metadata), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_metatags() { + let mut metadata = HashMap::new(); + metadata.insert("author".to_string(), "John Doe".to_string()); + metadata.insert( + "description".to_string(), + "A test page".to_string(), + ); + metadata + .insert("og:title".to_string(), "Test Title".to_string()); + + let meta_tags = generate_metatags(&metadata); + + assert!(meta_tags.primary.contains("author")); + assert!(meta_tags.primary.contains("description")); + assert!(meta_tags.og.contains("og:title")); + assert!(meta_tags.apple.is_empty()); + assert!(meta_tags.ms.is_empty()); + assert!(meta_tags.twitter.is_empty()); + } + + #[test] + fn test_add_custom_tag() { + let mut meta_tags = MetaTagGroups::default(); + meta_tags.add_custom_tag("custom-tag", "custom value"); + meta_tags.add_custom_tag("og:custom", "custom og value"); + + assert!(meta_tags.primary.contains("custom-tag")); + assert!(meta_tags.og.contains("og:custom")); } } diff --git a/ssg-metadata/src/models/mod.rs b/ssg-metadata/src/models/mod.rs deleted file mode 100644 index c0729312..00000000 --- a/ssg-metadata/src/models/mod.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Module containing data structures for metadata handling in the SSG system. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Represents metadata for a page or content item. -/// -/// This struct wraps a `HashMap` to store key-value pairs of metadata. -/// It provides methods to interact with the underlying data in a controlled manner. -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct Metadata { - inner: HashMap, -} - -impl Metadata { - /// Creates a new `Metadata` instance with the given data. - /// - /// # Arguments - /// - /// * `data` - A `HashMap` containing the initial metadata key-value pairs. - /// - /// # Returns - /// - /// A new `Metadata` instance. - pub fn new(data: HashMap) -> Self { - Metadata { inner: data } - } - - /// Retrieves the value associated with the given key. - /// - /// # Arguments - /// - /// * `key` - The key to look up. - /// - /// # Returns - /// - /// An `Option` containing a reference to the value if the key exists, or `None` if it doesn't. - pub fn get(&self, key: &str) -> Option<&String> { - self.inner.get(key) - } - - /// Retrieves a mutable reference to the value associated with the given key. - /// - /// # Arguments - /// - /// * `key` - The key to look up. - /// - /// # Returns - /// - /// An `Option` containing a mutable reference to the value if the key exists, or `None` if it doesn't. - pub fn get_mut(&mut self, key: &str) -> Option<&mut String> { - self.inner.get_mut(key) - } - - /// Inserts a key-value pair into the metadata. - /// - /// If the key already exists, the old value is replaced and returned. - /// - /// # Arguments - /// - /// * `key` - The key to insert. - /// * `value` - The value to associate with the key. - /// - /// # Returns - /// - /// An `Option` containing the old value if the key existed, or `None` if it didn't. - pub fn insert( - &mut self, - key: String, - value: String, - ) -> Option { - self.inner.insert(key, value) - } - - /// Checks if the metadata contains the given key. - /// - /// # Arguments - /// - /// * `key` - The key to check for. - /// - /// # Returns - /// - /// `true` if the key exists, `false` otherwise. - pub fn contains_key(&self, key: &str) -> bool { - self.inner.contains_key(key) - } - - /// Consumes the `Metadata` instance and returns the inner `HashMap`. - /// - /// # Returns - /// - /// The inner `HashMap` containing all metadata key-value pairs. - pub fn into_inner(self) -> HashMap { - self.inner - } - - /// Returns a reference to the inner `HashMap`. - /// - /// # Returns - /// - /// A reference to the inner `HashMap` containing all metadata key-value pairs. - pub fn as_inner_ref(&self) -> &HashMap { - &self.inner - } -} - -/// Holds collections of meta tags for different platforms and categories. -/// -/// This struct includes fields for Apple-specific meta tags, primary meta tags, Open Graph meta tags, -/// Microsoft-specific meta tags, and Twitter-specific meta tags. Each field contains a string -/// representation of the HTML meta tags for its respective category or platform. -#[derive( - Debug, Default, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, -)] -pub struct MetaTagGroups { - /// Meta tags specific to Apple devices - pub apple: String, - /// Primary meta tags, such as author, description, etc. - pub primary: String, - /// Open Graph meta tags, mainly used for social media - pub og: String, - /// Microsoft-specific meta tags - pub ms: String, - /// Twitter-specific meta tags - pub twitter: String, -} - -impl MetaTagGroups { - /// Creates a new `MetaTagGroups` instance with default values for all fields. - /// - /// # Returns - /// - /// A new `MetaTagGroups` instance with empty strings for all fields. - pub fn new() -> Self { - MetaTagGroups::default() - } - - /// Returns the value for the given key, if it exists. - /// - /// # Arguments - /// - /// * `key` - The key to look up. Valid keys are "apple", "primary", "og", "ms", and "twitter". - /// - /// # Returns - /// - /// An `Option` containing a reference to the value if the key exists, or `None` if it doesn't. - pub fn get(&self, key: &str) -> Option<&String> { - match key { - "apple" => Some(&self.apple), - "primary" => Some(&self.primary), - "og" => Some(&self.og), - "ms" => Some(&self.ms), - "twitter" => Some(&self.twitter), - _ => None, - } - } - - /// Checks if all fields are empty. - /// - /// # Returns - /// - /// `true` if all fields are empty strings, `false` otherwise. - pub fn is_empty(&self) -> bool { - self.apple.is_empty() - && self.primary.is_empty() - && self.og.is_empty() - && self.ms.is_empty() - && self.twitter.is_empty() - } -} - -/// Represents a single meta tag. -/// -/// This struct holds the name and content of a meta tag, which can be used to -/// generate a complete meta tag in HTML format. -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] -pub struct MetaTag { - /// The name of the meta tag. - pub name: String, - /// The content of the meta tag. - pub value: String, -} - -impl MetaTag { - /// Creates a new `MetaTag` instance with the given name and value. - /// - /// # Arguments - /// - /// * `name` - The name of the meta tag. - /// * `value` - The content of the meta tag. - /// - /// # Returns - /// - /// A new `MetaTag` instance. - pub fn new(name: String, value: String) -> Self { - MetaTag { name, value } - } - - /// Generates a complete meta tag in HTML format. - /// - /// # Returns - /// - /// A string representing the complete meta tag in HTML format. - pub fn generate(&self) -> String { - format!( - "", - self.value, self.name - ) - } - - /// Generates a complete list of meta tags in HTML format. - /// - /// # Arguments - /// - /// * `metatags` - A slice containing the `MetaTag` instances. - /// - /// # Returns - /// - /// A string representing the complete list of meta tags in HTML format. - pub fn generate_metatags(metatags: &[MetaTag]) -> String { - metatags.iter().map(MetaTag::generate).collect() - } -} diff --git a/ssg-metadata/src/processor.rs b/ssg-metadata/src/processor.rs deleted file mode 100644 index 662d4e54..00000000 --- a/ssg-metadata/src/processor.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::models::Metadata; -use anyhow::Result; - -/// Processes the extracted metadata. -pub fn process_metadata(metadata: &Metadata) -> Result { - let mut processed = metadata.clone(); - - // Convert dates to a standard format - if let Some(date) = processed.get_mut("date") { - *date = standardize_date(date); - } - - // Ensure required fields are present - ensure_required_fields(&mut processed)?; - - // Generate derived fields - generate_derived_fields(&mut processed); - - Ok(processed) -} - -fn standardize_date(date: &str) -> String { - // Implement date standardization logic here - // For example, convert various date formats to ISO 8601 - // This is a placeholder implementation - date.to_string() -} - -fn ensure_required_fields(metadata: &mut Metadata) -> Result<()> { - let required_fields = vec!["title", "date"]; - - for field in required_fields { - if !metadata.contains_key(field) { - anyhow::bail!("Missing required metadata field: {}", field); - } - } - - Ok(()) -} - -fn generate_derived_fields(metadata: &mut Metadata) { - // Generate a URL slug from the title if not present - if !metadata.contains_key("slug") { - if let Some(title) = metadata.get("title") { - let slug = generate_slug(title); - metadata.insert("slug".to_string(), slug); - } - } -} - -fn generate_slug(title: &str) -> String { - // Implement slug generation logic here - // This is a simple placeholder implementation - title.to_lowercase().replace(' ', "-") -} diff --git a/ssg-metadata/src/utils.rs b/ssg-metadata/src/utils.rs new file mode 100644 index 00000000..033e2f5c --- /dev/null +++ b/ssg-metadata/src/utils.rs @@ -0,0 +1,123 @@ +use crate::error::MetadataError; +use crate::extract_and_prepare_metadata; +use crate::metatags::MetaTagGroups; +use std::collections::HashMap; +use tokio::fs::File; +use tokio::io::AsyncReadExt; + +/// Escapes special HTML characters in a string. +/// +/// # Arguments +/// +/// * `value` - The string to escape. +/// +/// # Returns +/// +/// A new string with special HTML characters escaped. +/// +/// # Example +/// +/// ``` +/// use ssg_metadata::escape_html_entities; +/// +/// let input = "Hello, !"; +/// let expected = "Hello, <world>!"; +/// +/// assert_eq!(escape_html_entities(input), expected); +/// ``` +pub fn escape_html_entities(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('\"', """) + .replace('\'', "'") +} + +/// Asynchronously reads a file and extracts metadata from its content. +/// +/// This function reads the content of a file asynchronously and then extracts +/// metadata, generates keywords, and prepares meta tag groups. +/// +/// # Arguments +/// +/// * `file_path` - A string slice representing the path to the file. +/// +/// # Returns +/// +/// Returns a Result containing a tuple with: +/// * `HashMap`: Extracted metadata +/// * `Vec`: A list of keywords +/// * `MetaTagGroups`: A structure containing various meta tags +/// +/// # Errors +/// +/// This function will return a `MetadataError` if file reading, metadata extraction, or processing fails. +/// +pub async fn async_extract_metadata_from_file( + file_path: &str, +) -> Result< + (HashMap, Vec, MetaTagGroups), + MetadataError, +> { + let mut file = File::open(file_path) + .await + .map_err(MetadataError::IoError)?; + let mut content = String::new(); + file.read_to_string(&mut content) + .await + .map_err(MetadataError::IoError)?; + + extract_and_prepare_metadata(&content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_html_entities() { + let input = "Hello, & \"friends\"!"; + let expected = + "Hello, <world> & "friends"!"; + assert_eq!(escape_html_entities(input), expected); + } + + #[tokio::test] + async fn test_async_extract_metadata_from_file() { + use tokio::fs::File; + use tokio::io::AsyncWriteExt; + + // Create a temporary file with some content + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.md"); + let mut file = File::create(&file_path).await.unwrap(); + let content = r#"--- +title: Test Page +description: A test page for metadata extraction +keywords: test, metadata, extraction +--- +# Test Content +This is a test file for metadata extraction."#; + file.write_all(content.as_bytes()).await.unwrap(); + + // Test the async_extract_metadata_from_file function + let result = async_extract_metadata_from_file( + file_path.to_str().unwrap(), + ) + .await; + assert!(result.is_ok()); + + let (metadata, keywords, meta_tags) = result.unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Page".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some(&"A test page for metadata extraction".to_string()) + ); + assert_eq!(keywords, vec!["test", "metadata", "extraction"]); + assert!(!meta_tags.primary.is_empty()); + } +} diff --git a/ssg-metadata/tests/integration_tests.rs b/ssg-metadata/tests/integration_tests.rs deleted file mode 100644 index 8b137891..00000000 --- a/ssg-metadata/tests/integration_tests.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ssg-metadata/tests/test_error.rs b/ssg-metadata/tests/test_error.rs new file mode 100644 index 00000000..ab955f6a --- /dev/null +++ b/ssg-metadata/tests/test_error.rs @@ -0,0 +1,75 @@ +//! Unit tests for the `error` module in the ssg-metadata library. +//! +//! This module tests the various custom error types defined in `MetadataError` +//! and their functionality. + +#[cfg(test)] +mod tests { + use ssg_metadata::error::MetadataError; + use std::io; + + /// Test `ExtractionError` construction. + /// + /// This test ensures that the `ExtractionError` variant is created and its message is correct. + #[test] + fn test_extraction_error() { + let error = MetadataError::ExtractionError( + "Invalid metadata format".to_string(), + ); + assert_eq!( + error.to_string(), + "Failed to extract metadata: Invalid metadata format" + ); + } + + /// Test `ProcessingError` construction. + /// + /// This test ensures that the `ProcessingError` variant is created and its message is correct. + #[test] + fn test_processing_error() { + let error = + MetadataError::ProcessingError("Unknown field".to_string()); + assert_eq!( + error.to_string(), + "Failed to process metadata: Unknown field" + ); + } + + /// Test `MissingFieldError` construction. + /// + /// This test ensures that the `MissingFieldError` variant is created and its message is correct. + #[test] + fn test_missing_field_error() { + let error = + MetadataError::MissingFieldError("description".to_string()); + assert_eq!( + error.to_string(), + "Missing required metadata field: description" + ); + } + + /// Test `DateParseError` construction. + /// + /// This test ensures that the `DateParseError` variant is created and its message is correct. + #[test] + fn test_date_parse_error() { + let error = MetadataError::DateParseError( + "Invalid date format".to_string(), + ); + assert_eq!( + error.to_string(), + "Failed to parse date: Invalid date format" + ); + } + + /// Test `IoError` conversion. + /// + /// This test ensures that a standard `io::Error` is correctly converted into the `IoError` variant. + #[test] + fn test_io_error() { + let io_error = + io::Error::new(io::ErrorKind::NotFound, "File not found"); + let error = MetadataError::from(io_error); + assert_eq!(error.to_string(), "I/O error: File not found"); + } +} diff --git a/ssg-metadata/tests/test_integration.rs b/ssg-metadata/tests/test_integration.rs new file mode 100644 index 00000000..ee29ed6d --- /dev/null +++ b/ssg-metadata/tests/test_integration.rs @@ -0,0 +1,152 @@ +#[cfg(test)] +mod integration_tests { + use ssg_metadata::error::MetadataError; + use ssg_metadata::metadata::extract_metadata; + use ssg_metadata::metatags::generate_metatags; + use ssg_metadata::utils::escape_html_entities; + + /// Integration test: Metadata extraction and meta tag generation. + /// + /// This test verifies that metadata extraction from content works correctly and meta tags are generated properly. + #[test] + fn test_metadata_and_metatags_integration() { + let content = r#" +--- +title: "Integration Test Page" +description: "This is a page for integration testing." +keywords: "integration, test, metadata" +--- +# Content for integration testing. +"#; + + // Extract metadata from content + let metadata = extract_metadata(content) + .expect("Failed to extract metadata"); + + // Verify extracted metadata + assert_eq!( + metadata.get("title"), + Some(&"Integration Test Page".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some( + &"This is a page for integration testing.".to_string() + ) + ); + assert_eq!( + metadata.get("keywords"), + Some(&"integration, test, metadata".to_string()) + ); + + // Generate meta tags from the extracted metadata + let metatags = generate_metatags(&metadata.into_inner()); + + // Verify the generated meta tags + assert!(metatags.primary.contains("description")); + assert!(metatags.primary.contains("keywords")); + } + + /// Integration test: HTML escaping and metadata processing. + /// + /// This test ensures that HTML content is properly escaped and metadata is processed correctly. + #[test] + fn test_html_escaping_and_metadata() { + let html_content = r#" +--- +title: "Escaping Test" +description: "" +keywords: "escape, html, test" +--- +# Content for escaping test. +"#; + + // Extract metadata from content + let metadata = extract_metadata(html_content) + .expect("Failed to extract metadata"); + + // Escape HTML characters in metadata fields + let escaped_description = + escape_html_entities(metadata.get("description").unwrap()); + + // Verify that HTML in the description is escaped + assert_eq!( + escaped_description, + "<script>alert('test');</script>" + ); + } + + /// Integration test: Metadata extraction and error handling. + /// + /// This test checks that an invalid front matter format results in an appropriate error. + #[test] + fn test_metadata_extraction_error_handling() { + let invalid_content = r#" +--- +title Integration Test Page +description: This is an invalid front matter format. +--- +# Content for invalid test. +"#; + + // Try to extract metadata from invalid content + let result = extract_metadata(invalid_content); + + // Verify that an error is returned + assert!(result.is_err()); + + // Check for the specific type of error (MetadataError::ExtractionError) + if let Err(MetadataError::ExtractionError(_)) = result { + // Expected error + } else { + panic!("Expected MetadataError::ExtractionError"); + } + } + + /// Integration test: Metadata extraction from file and meta tag generation. + /// + /// This async test ensures that metadata can be extracted from a file and meta tags generated correctly. + #[tokio::test] + async fn test_async_metadata_and_metatags_integration() { + use tempfile::tempdir; + use tokio::fs::File; + use tokio::io::AsyncWriteExt; + + // Create a temporary file with some content + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_async.md"); + let mut file = File::create(&file_path).await.unwrap(); + let content = r#" +--- +title: "Async Test Page" +description: "This is an async test for metadata extraction." +keywords: "async, test, metadata" +--- +# Async Test Content +"#; + file.write_all(content.as_bytes()).await.unwrap(); + + // Test the async_extract_metadata_from_file function + let result = + ssg_metadata::utils::async_extract_metadata_from_file( + file_path.to_str().unwrap(), + ) + .await; + assert!(result.is_ok()); + + let (metadata, keywords, meta_tags) = result.unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Async Test Page".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some( + &"This is an async test for metadata extraction." + .to_string() + ) + ); + assert_eq!(keywords, vec!["async", "test", "metadata"]); + assert!(!meta_tags.primary.is_empty()); + } +} diff --git a/ssg-metadata/tests/test_lib.rs b/ssg-metadata/tests/test_lib.rs new file mode 100644 index 00000000..869a4c96 --- /dev/null +++ b/ssg-metadata/tests/test_lib.rs @@ -0,0 +1,115 @@ +#[cfg(test)] +mod tests { + use ssg_metadata::{ + self, extract_and_prepare_metadata, MetadataError, + }; + + /// Test the `extract_and_prepare_metadata` function with valid content. + /// + /// This test ensures that metadata extraction, keyword generation, and meta tag creation + /// work correctly for valid input content. + #[test] + fn test_extract_and_prepare_metadata_valid() { + let content = r#"--- +title: "My Page" +description: "A sample page" +keywords: "rust, static site generator, metadata" +--- +# Content goes here +"#; + + let result = extract_and_prepare_metadata(content); + assert!( + result.is_ok(), + "Metadata extraction should succeed for valid content" + ); + + let (metadata_map, keywords, meta_tags) = result.unwrap(); + + // Ensure metadata is correctly extracted + assert_eq!( + metadata_map.get("title"), + Some(&"My Page".to_string()), + "Title metadata should be extracted correctly" + ); + assert_eq!( + metadata_map.get("description"), + Some(&"A sample page".to_string()), + "Description metadata should be extracted correctly" + ); + + // Ensure keywords are correctly generated + assert_eq!( + keywords, + vec!["rust", "static site generator", "metadata"], + "Keywords should be extracted correctly" + ); + + // Ensure meta tags contain the correct description + assert!( + meta_tags.primary.contains("description"), + "Primary meta tags should contain 'description'" + ); + } + + /// Test the `extract_and_prepare_metadata` function with missing metadata. + /// + /// This test ensures that missing metadata fields are handled gracefully. + #[test] + fn test_extract_and_prepare_metadata_missing_metadata() { + let content = r#"--- +title: "No Description" +--- +# Content goes here +"#; + + let result = extract_and_prepare_metadata(content); + assert!(result.is_ok(), "Metadata extraction should succeed even with missing fields"); + + let (metadata_map, keywords, meta_tags) = result.unwrap(); + + // Ensure title is correctly extracted, even if description is missing + assert_eq!( + metadata_map.get("title"), + Some(&"No Description".to_string()), + "Title metadata should be extracted correctly" + ); + assert_eq!( + keywords.len(), + 0, + "No keywords should be extracted if none are provided" + ); + + // Ensure the description is absent from the meta tags + assert!( + !meta_tags.primary.contains("description"), + "Primary meta tags should not contain 'description'" + ); + } + + /// Test the `extract_and_prepare_metadata` function with invalid front matter format. + /// + /// This test checks whether the function returns an appropriate error when the front matter is malformed. + #[test] + fn test_extract_and_prepare_metadata_invalid_format() { + let content = r#"--- +title My Page +description A sample page +--- +# Content goes here +"#; + + let result = extract_and_prepare_metadata(content); + assert!( + result.is_err(), + "Invalid front matter format should result in an error" + ); + + // Ensure the error is of type MetadataError::ExtractionError + if let Err(MetadataError::ExtractionError(_)) = result { + // This is the expected error + } else { + panic!("Expected MetadataError::ExtractionError"); + } + } +} diff --git a/ssg-metadata/tests/test_metadata.rs b/ssg-metadata/tests/test_metadata.rs new file mode 100644 index 00000000..b86ae2c6 --- /dev/null +++ b/ssg-metadata/tests/test_metadata.rs @@ -0,0 +1,122 @@ +//! Unit tests for the `metadata` module. +//! +//! This module contains tests for metadata extraction and manipulation. + +#[cfg(test)] +mod tests { + use ssg_metadata::metadata::extract_metadata; + use ssg_metadata::MetadataError; + + /// Test metadata extraction from a valid YAML source. + /// + /// This test ensures that metadata is correctly extracted from valid YAML input. + #[test] + fn test_yaml_metadata_extraction() { + let yaml = r#"--- +title: "Test Title" +description: "Test description" +--- +Content here +"#; + + let metadata = extract_metadata(yaml).unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Title".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some(&"Test description".to_string()) + ); + } + + /// Test metadata extraction from a valid TOML source. + /// + /// This test ensures that metadata is correctly extracted from valid TOML input. + /// Test metadata extraction from a valid TOML source. + #[test] + fn test_toml_metadata_extraction() { + let toml = r#" ++++ +title = "Test Title" +description = "Test description" ++++ +Content here +"#; + + let metadata = extract_metadata(toml).unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Title".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some(&"Test description".to_string()) + ); + } + + /// Test metadata extraction from a valid JSON source. + #[test] + fn test_json_metadata_extraction() { + let json = r#" +{ + "title": "Test Title", + "description": "Test description" +} +Content here +"#; + + let metadata = extract_metadata(json).unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Title".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some(&"Test description".to_string()) + ); + } + + /// Test metadata extraction with missing fields. + /// + /// This test checks how the module handles cases where certain metadata fields are absent. + #[test] + fn test_missing_metadata() { + let yaml = r#"--- +title: "Test Title" +--- +Content here +"#; + + let metadata = extract_metadata(yaml).unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Title".to_string()) + ); + assert!(metadata.get("description").is_none()); + } + + /// Test metadata extraction with invalid front matter format. + /// + /// This test checks if the function returns an appropriate error when the front matter is malformed. + #[test] + fn test_invalid_metadata_format() { + let invalid_yaml = r#"--- +title: Test Title +description: Test description +Content here +"#; + + let result = extract_metadata(invalid_yaml); + assert!( + result.is_err(), + "Invalid YAML front matter should result in an error" + ); + + if let Err(MetadataError::ExtractionError(_)) = result { + // Expected error + } else { + panic!("Expected MetadataError::ExtractionError"); + } + } +} diff --git a/ssg-metadata/tests/test_metatags.rs b/ssg-metadata/tests/test_metatags.rs new file mode 100644 index 00000000..5dddf15f --- /dev/null +++ b/ssg-metadata/tests/test_metatags.rs @@ -0,0 +1,51 @@ +//! Unit tests for the `metatags` module. +//! +//! This module tests the handling and validation of meta tags in HTML documents. + +#[cfg(test)] +mod tests { + use regex::Regex; + use std::collections::HashMap; + + /// Parses meta tags from an HTML string and returns a HashMap. + fn parse_metatags(html: &str) -> HashMap { + let mut metatags = HashMap::new(); + let re = Regex::new(r#""#).unwrap(); + + for cap in re.captures_iter(html) { + // Only add the meta tag if the content is not empty + if !cap[2].trim().is_empty() { + metatags.insert(cap[1].to_string(), cap[2].to_string()); + } + } + + metatags + } + + /// Test if valid meta tags are correctly identified. + /// + /// This test checks that the system correctly parses and validates meta tags. + #[test] + fn test_valid_metatags() { + let html = ""; + let metatags = parse_metatags(html); + assert!(metatags.contains_key("keywords")); + assert_eq!( + metatags.get("keywords"), + Some(&"rust, testing".to_string()) + ); + } + + /// Test for invalid meta tags. + /// + /// This test ensures that invalid or malformed meta tags are handled gracefully. + #[test] + fn test_invalid_metatags() { + let html = ""; + let metatags = parse_metatags(html); + assert!( + metatags.is_empty(), + "Empty meta tags should not be parsed" + ); + } +} diff --git a/ssg-metadata/tests/test_utils.rs b/ssg-metadata/tests/test_utils.rs new file mode 100644 index 00000000..f5abafd8 --- /dev/null +++ b/ssg-metadata/tests/test_utils.rs @@ -0,0 +1,83 @@ +//! Unit tests for the `utils` module. +//! +//! This module tests utility functions such as string manipulation and validation. + +#[cfg(test)] +mod tests { + use ssg_metadata::async_extract_metadata_from_file; + use ssg_metadata::utils::escape_html_entities; + + /// Test if string escaping works as expected. + /// + /// This test ensures that the escaping function properly handles unsafe characters. + #[test] + fn test_string_escaping() { + let input = ""; + let escaped = escape_html_entities(input); + let expected = + "<script>alert('test');</script>"; // No forward slash escaping + assert_eq!(escaped, expected, "The escaped HTML entities should match the expected result."); + } + + /// Test for invalid input in the utility function. + /// + /// This test checks how the utility function handles invalid or unsafe input. + #[test] + fn test_invalid_input_handling() { + let input = ""; + let result = escape_html_entities(input); + assert_eq!( + result, "", + "Empty string should return empty result" + ); + } + + /// Test escaping of a variety of special HTML characters. + #[test] + fn test_escape_html_entities() { + let input = "Hello, & \"friends\"!"; + let expected = + "Hello, <world> & "friends"!"; + assert_eq!(escape_html_entities(input), expected); + } + + /// Test async file-based metadata extraction. + #[tokio::test] + async fn test_async_extract_metadata_from_file() { + use tempfile::tempdir; + use tokio::fs::File; + use tokio::io::AsyncWriteExt; + + // Create a temporary file with some content + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test.md"); + let mut file = File::create(&file_path).await.unwrap(); + let content = r#"--- +title: Test Page +description: A test page for metadata extraction +keywords: test, metadata, extraction +--- +# Test Content +This is a test file for metadata extraction."#; + file.write_all(content.as_bytes()).await.unwrap(); + + // Test the async_extract_metadata_from_file function + let result = async_extract_metadata_from_file( + file_path.to_str().unwrap(), + ) + .await; + assert!(result.is_ok()); + + let (metadata, keywords, meta_tags) = result.unwrap(); + assert_eq!( + metadata.get("title"), + Some(&"Test Page".to_string()) + ); + assert_eq!( + metadata.get("description"), + Some(&"A test page for metadata extraction".to_string()) + ); + assert_eq!(keywords, vec!["test", "metadata", "extraction"]); + assert!(!meta_tags.primary.is_empty()); + } +}