diff --git a/Documentation/HowTo/generate-multi-language-site.md b/Documentation/HowTo/generate-multi-language-site.md new file mode 100644 index 00000000..08cdfb3f --- /dev/null +++ b/Documentation/HowTo/generate-multi-language-site.md @@ -0,0 +1,203 @@ +# Generate Multi-Language Site + +This post talks about how to produce multi-language site using Publish. +For example, `https://example.com/en/post/title/` and `https://example.com/zh/post/title/` for two language versions of the same post, where `en` and `zh` specifies language. + +This feature did not come with standard `Website` struct of Publish. It needs some tweaks to make this possible. + +## Basic Usage + +### Generate Publish Project + +Generate a new Publish project if you haven't got one. Check introduction in official readme. [https://github.com/JohnSundell/Publish](https://github.com/JohnSundell/Publish) + +If you already have a working Publish project, you may skip this step and modify the existing one. + +### Setup Your Website + +- Modify your website to conform to `MultiLanguageWebsite` instead of `Website`. +- Add a new variable `var languages: [Language]`, an array of languages your website has. +- Modify the `ItemMetadata` to conform to `MultiLanguageWebsiteItemMetadata` instead of `WebsiteItemMetadata`. +- Add a variable in `ItemMetadata`: `var alternateLinkIdentifier: String?`. +- *Optional* Remove the `var language: Language` variable, or change it to default language of your website. + +A modified version of the website that supports English and Chinese would look like below: + +```swift +// This type acts as the configuration for your website. +struct InternationalWebsite: MultiLanguageWebsite { + // The languages of your website. The first language becomes default language. + var languages: [Language] = [.english, .chinese, .japanese] + + enum SectionID: String, WebsiteSectionID { + // Add the sections that you want your website to contain here: + case posts + } + + struct ItemMetadata: MultiLanguageWebsiteItemMetadata { + // A metadata entry to correlate posts in different languages. + var alternateLinkIdentifier: String? + + // Add any site-specific metadata that you want to use here. + } + + // Update these properties to configure your website: + var url = URL(string: "https://your-website-url.com")! + var name = "InternationalWebsite + var description = "A description of InternationalWebsite" + var imagePath: Path? { nil } +} +``` + +### Arrange Markdown Files + +By default, the markdown files for posts are located in 'Content' folder, with the following structure: + +``` +Content/ + SectionOne/ + PostOne.md + PostTwo.md + SectionTwo/ + PostThree.md +``` + +Change the above structure and make a folder for each language, where the folder name is language code of the language. +You may find a list of supported languages in `Language.swift` of Plot. + +``` +en/ + SectionOne/ + PostOne.md + PostTwo.md + SectionTwo/ + PostThree.md + +zh/ + SectionOne/ + PostOne.md + PostTwo.md + SectionTwo/ + PostThree.md + +ja/ + SectionOne/ + PostOne.md + PostTwo.md + SectionTwo/ + PostThree.md +``` + +### Localize Theme + +Localize the Theme you are using to show different versions for languages. + +For example, the `makeIndexHTML()` becomes + +```swift +func makeIndexHTML(for index: Index, + context: PublishingContext) throws -> HTML { + HTML( + .lang(index.language!), // Use language of the index. + .head(for: index, on: context.site), + .body( + .header(for: context, selectedSection: nil), + .wrapper( + .h1(.text(index.title)), + .p( + .class("description"), + .text(context.site.description) + ), + .h2("Latest content"), + .itemList( + for: context.allItems( + sortedBy: \.date, + in: index.language!, // Get items in the specific language only. + order: .descending + ), + on: context.site + ) + ), + .footer(for: context.site) + ) + ) +} +``` + +You may need to localize the text of the website, *e.g.* section names, links, etc. + +It is the time to use your creativity to make your own website localized. + +## More Usage + +### Customize the name of content folder name of each language. + +By default, Publish process markdown files of each language located in folder named by language code, *e.g.* en, zh, etc. +To change it, implement the following method of your website: + +``` +extension InternationalWebsite { + func contentFolder(for language: Language) -> String { + switch language { + case .english: + return "English Content" // "en" by default + case .chinese: + return "Chinese Content" // "zh" by default + case .japanese: + return "Japanese Content" // "ja" by default + default: + return language.rawValue + } + } +} +``` + +### Customize the language component of output path. + +By default, Publish outputs html files of each language located in folder named by language code, *e.g.* en, zh, etc. +To change it, implement the following method of your website: + +``` +extension InternationalWebsite { + func pathPrefix(for language: Language) -> String { + switch language { + case .english: + return "us" // "en" by default + case .chinese: + return "cn" // "zh" by default + case .japanese: + return "jp" // "ja" by default + default: + return language.rawValue + } + } +} +``` + +### Specify the language of markdown file. + +You may specify then language of markdown file by setting `language` to language code in metadata. + +``` +--- +language: en +--- +``` + +### Correlate markdown files in different languages + +By default, markdown files with the same path are considered as language variations of the same item. +If you want to correlate markdown files with different file name, e.g. + +- en/Section/example.md in English +- zh/Section/样例.md in Chinese +- ja/Section/例.md in Japanese + +You need to correlate them by setting `alternateLinkIdentifier` to the same value in metadata of the above files. + +``` +--- +alternateLinkIdentifier: example-markdown +--- +``` + diff --git a/Documentation/README.md b/Documentation/README.md index 33e81daf..f66d8df8 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -15,5 +15,6 @@ Shorter articles focused on explaining how to get a given task done using Publis - [Expressing custom metadata values using Markdown](HowTo/custom-markdown-metadata-values.md) - [Nesting items within folders](HowTo/nested-items.md) - [Using a custom `DateFormatter`](HowTo/using-a-custom-date-formatter.md) +- [Generate Multi-Language Site](HowTo/generate-multi-language-site.md) *Contributions adding more “How to” articles, or other kinds of documentation, are more than welcome.* diff --git a/Sources/Publish/API/Content.swift b/Sources/Publish/API/Content.swift index 69f9c636..2b8efed9 100644 --- a/Sources/Publish/API/Content.swift +++ b/Sources/Publish/API/Content.swift @@ -17,6 +17,7 @@ public struct Content: Hashable, ContentProtocol { public var imagePath: Path? public var audio: Audio? public var video: Video? + public var language: Language? /// Initialize a new instance of this type /// - parameter title: The location's title. @@ -24,6 +25,7 @@ public struct Content: Hashable, ContentProtocol { /// - parameter body: The main body of the location's content. /// - parameter date: The location's main publishing date. /// - parameter lastModified: The last modification date. + /// - parameter language: The language of the content. /// - parameter imagePath: A path to any image for the location. /// - parameter audio: Any audio data associated with this content. /// - parameter video: Any video data associated with this content. @@ -32,6 +34,7 @@ public struct Content: Hashable, ContentProtocol { body: Body = Body(html: ""), date: Date = Date(), lastModified: Date = Date(), + language: Language? = nil, imagePath: Path? = nil, audio: Audio? = nil, video: Video? = nil) { @@ -40,6 +43,7 @@ public struct Content: Hashable, ContentProtocol { self.body = body self.date = date self.lastModified = lastModified + self.language = language self.imagePath = imagePath self.audio = audio self.video = video diff --git a/Sources/Publish/API/ContentProtocol.swift b/Sources/Publish/API/ContentProtocol.swift index 775646b3..eb987a43 100644 --- a/Sources/Publish/API/ContentProtocol.swift +++ b/Sources/Publish/API/ContentProtocol.swift @@ -5,6 +5,7 @@ */ import Foundation +import Plot /// Protocol adopted by types that represent the content for a location. public protocol ContentProtocol { @@ -43,4 +44,8 @@ public protocol ContentProtocol { /// can be used to display inline video players using the `videoPlayer` /// Plot component. See `Video` for more info. var video: Video? { get set } + /// The language of the location's content, which + /// can be used to specify the language using the `lang` + /// Plot component. See `Language` for more info. + var language: Language? { get set } } diff --git a/Sources/Publish/API/Location.swift b/Sources/Publish/API/Location.swift index e6f78136..e2ed4c0f 100644 --- a/Sources/Publish/API/Location.swift +++ b/Sources/Publish/API/Location.swift @@ -5,6 +5,7 @@ */ import Foundation +import Plot /// Protocol adopted by types that can act as a location /// that a user can navigate to within a web browser. @@ -62,4 +63,9 @@ public extension Location { get { content.video } set { content.video = newValue } } + + var language: Language? { + get { content.language } + set { content.language = newValue } + } } diff --git a/Sources/Publish/API/MultiLanguageWebsite.swift b/Sources/Publish/API/MultiLanguageWebsite.swift new file mode 100644 index 00000000..4ebd3b1b --- /dev/null +++ b/Sources/Publish/API/MultiLanguageWebsite.swift @@ -0,0 +1,224 @@ +import Foundation +import Plot + +/// Protocol that all `MultiLanguageWebsite.ItemMetadata` implementations must conform to. +public protocol MultiLanguageWebsiteItemMetadata : WebsiteItemMetadata { + /// A unique identifier for the same items in different languages to correlate the items. + var alternateLinkIdentifier: String? { get set } +} + +/// Protocol used to define a Publish-based multi-language website. +/// You conform to this protocol using a custom type, which is then used to +/// infer various information about your website when generating its various +/// HTML pages and resources. A website is then published using a pipeline made +/// up of `PublishingStep` values, which is constructed using the `publish` method. +/*/// To generate the necessary bootstrapping for conforming to this protocol, use +/// the `publish new` command line tool.*/ +//todo: make cli, write document +public protocol MultiLanguageWebsite: Website where ItemMetadata: MultiLanguageWebsiteItemMetadata { + /// Languages of the website to generate. The first language becomes default language. + /// Can't be empty. + var languages: [Language] { get } + /// The folder name of the markdown files of each language. + /// Default implementation returns language code defined in ISO 639. + /// - Parameter language: The language of the files in returned folder. + func contentFolder(for language: Language) -> String + /// The prefix of the path of items in different languages. + /// Default implementation returns language code defined in ISO 639. + /// e.g. the `en ` in `https://example.com/en/index` for English. + /// - Parameter language: The language of the prefix. + func pathPrefix(for language: Language) -> String +} + +// MARK: - Default implementations + +public extension MultiLanguageWebsite { + /// Default language of the website. + var language: Language { languages.first! } + + func contentFolder(for language: Language) -> String { + language.rawValue + } + + func pathPrefix(for language: Language) -> String { + language.rawValue + } + + /// Publish this website using a default pipeline. To build a completely + /// custom pipeline, use the `publish(using:)` method. + /// - parameter theme: The HTML theme to generate the website using. + /// - parameter indentation: How to indent the generated files. + /// - parameter path: Any specific path to generate the website at. + /// - parameter rssFeedSections: What sections to include in the site's RSS feed. + /// - parameter rssFeedConfig: The configuration to use for the site's RSS feed. + /// - parameter deploymentMethod: How to deploy the website. + /// - parameter additionalSteps: Any additional steps to add to the publishing + /// pipeline. Will be executed right before the HTML generation process begins. + /// - parameter plugins: Plugins to be installed at the start of the publishing process. + /// - parameter file: The file that this method is called from (auto-inserted). + /// - parameter line: The line that this method is called from (auto-inserted). + @discardableResult + func publish(withTheme theme: Theme, + indentation: Indentation.Kind? = nil, + at path: Path? = nil, + rssFeedSections: Set = Set(SectionID.allCases), + rssFeedConfig: RSSFeedConfiguration? = .default, + deployedUsing deploymentMethod: DeploymentMethod? = nil, + additionalSteps: [PublishingStep] = [], + plugins: [Plugin] = [], + file: StaticString = #file) throws -> PublishedWebsite { + try publish( + at: path, + using: [ + .group(plugins.map(PublishingStep.installPlugin)), + .optional(.copyResources()), + .addMarkdownFiles(), + .sortItems(by: \.date, order: .descending), + .group(additionalSteps), + .generateHTML(withTheme: theme, indentation: indentation), + .copyDefaultIndexHtml(), + .unwrap(rssFeedConfig) { config in + .generateRSSFeed( + including: rssFeedSections, + config: config + ) + }, + .generateSiteMap(indentedBy: indentation), + .unwrap(deploymentMethod, PublishingStep.deploy) + ], + file: file + ) + } + + /// Publish this website using a custom pipeline. + /// - parameter path: Any specific path to generate the website at. + /// - parameter steps: The steps to use to form the website's publishing pipeline. + /// - parameter file: The file that this method is called from (auto-inserted). + /// - parameter line: The line that this method is called from (auto-inserted). + @discardableResult + func publish(at path: Path? = nil, + using steps: [PublishingStep], + file: StaticString = #file) throws -> PublishedWebsite { + let pipeline = PublishingPipeline( + steps: steps, + originFilePath: Path("\(file)") + ) + return try pipeline.execute(for: self, at: path) + } +} + + +// MARK: - Paths and URLs + +public extension MultiLanguageWebsite { + /// The path for the location, in specified language. + /// - Parameter language: Specified Language. + func path(for location: Location) -> Path { + if let index = location as? Index { + return self.path(for: index) + } + if let section = location as? Section { + return self.path(for: section) + } + if let item = location as? Item { + return self.path(for: item) + } + if let page = location as? Page { + return self.path(for: page) + } + if location is TagListPage { + return self.tagListPath(in: location.language!) + } + if let tag = location as? TagDetailsPage { + return self.path(for: tag.tag, in: tag.language!) + } + return location.path + } + + /// The path for the website's tag list page in the specified language. + /// - Parameter language: Specified Language. + func tagListPath(in language: Language) -> Path { + Path(pathPrefix(for: language)).appendingComponent(tagListPath.string+"/") + } + + /// Return the relative path for a given section ID in the specified language. + /// - parameter sectionID: The section ID to return a path for. + /// - Parameter language: Specified Language. + func path(for sectionID: SectionID, in language: Language) -> Path { + Path(pathPrefix(for: language)).appendingComponent(path(for: sectionID).string+"/") + } + + /// Return the relative path for a given tag in specified language. + /// - parameter tag: The tag to return a path for. + /// - Parameter language: Specified Language. + func path(for tag: Tag, in language: Language) -> Path { + let basePath = Path(pathPrefix(for: language)) + .appendingComponent((tagHTMLConfig?.basePath ?? .defaultForTagHTML).string) + // decomposedStringWithCanonicalMapping is used to fix encoding incompatibility of Japanese url. + // e.g. が => か゛ + return language == .japanese ? basePath.appendingComponent(tag.normalizedString().decomposedStringWithCanonicalMapping + "/") : basePath.appendingComponent(tag.normalizedString() + "/") + } + + /// Return the absolute URL for a given tag in specified language. + /// - parameter tag: The tag to return a URL for. + /// - Parameter language: Specified Language. + func url(for tag: Tag, in language: Language) -> URL { + url(for: path(for: tag), in: language) + } + + /// Return the absolute URL for a given path. + /// - parameter path: The path to return a URL for. + func url(for path: Path, in language: Language) -> URL { + guard !path.string.isEmpty else { return url } + if language == .japanese { + return url.appendingPathComponent("\(pathPrefix(for: language))/\(path.string.decomposedStringWithCanonicalMapping)/") + } + return url.appendingPathComponent("\(pathPrefix(for: language))/\(path.string)/") + } + + /// Return the absolute URL for a given location. + /// - parameter location: The location to return a URL for. + func url(for location: Location) -> URL { + if let index = location as? Index { + return url(for: index, in: location.language!) + } + return url(for: location.path, in: location.language!) + } + + /// Return the relative path for a given item. + /// - parameter item: The item to return a path for. + /// - Parameter language: Specified Language. + func path(for item: Item) -> Path { + let language = item.language ?? self.language + if language == .japanese { + return Path(pathPrefix(for: language)).appendingComponent("\(item.sectionID.rawValue)/\(item.relativePath.string.decomposedStringWithCanonicalMapping)/") + } else { + return Path(pathPrefix(for: language)).appendingComponent("\(item.sectionID.rawValue)/\(item.relativePath)/") + } + } + + /// Return the relative path for a given section. + /// - parameter section: The section to return a path for. + /// - Parameter language: Specified Language. + func path(for section: Section) -> Path { + Path("\(pathPrefix(for: section.language!))/\(section.id.rawValue)/") + } + + /// Return the relative path for a given index. + /// - parameter section: The section to return a path for. + func path(for index: Index) -> Path { + Path("\(pathPrefix(for: index.language!))/index.html") + } + + /// Return the relative path for a given page. + /// - parameter page: The page to return a path for. + func path(for page: Page) -> Path { + Path("\(pathPrefix(for: page.language!))/\(page.path)/") + } + + /// Return the absolute URL for a given path. + /// - parameter path: The path to return a URL for. + func url(for index: Index, in language: Language) -> URL { + url.appendingPathComponent(pathPrefix(for: language)+"/") + } +} diff --git a/Sources/Publish/API/PlotComponents.swift b/Sources/Publish/API/PlotComponents.swift index af6a9db2..9e285ebf 100644 --- a/Sources/Publish/API/PlotComponents.swift +++ b/Sources/Publish/API/PlotComponents.swift @@ -211,3 +211,78 @@ private extension Node where Context: HTML.BodyContext { ) } } + +internal extension Node where Context: RSSItemContext { + static func guid(for item: Item, site: T) -> Node { + return .guid( + .text(item.rssProperties.guid ?? site.url(for: item).absoluteString), + .isPermaLink(item.rssProperties.guid == nil && item.rssProperties.link == nil) + ) + } +} + +public extension Node where Context == HTML.DocumentContext { + /// Add an HTML `` tag within the current context, based + /// on inferred information from the current location and `Website` + /// implementation. + /// - parameter location: The location to generate a `` tag for. + /// - parameter site: The website on which the location is located. + /// - parameter titleSeparator: Any string to use to separate the location's + /// title from the name of the website. Default: `" | "`. + /// - parameter stylesheetPaths: The paths to any stylesheets to add to + /// the resulting HTML page. Default: `styles.css`. + /// - parameter rssFeedPath: The path to any RSS feed to associate with the + /// resulting HTML page. Default: `feed.rss`. + /// - parameter rssFeedTitle: An optional title for the page's RSS feed. + static func head( + for location: Location, + on site: T, + //in language: Language, + titleSeparator: String = " | ", + stylesheetPaths: [Path] = ["/styles.css"], + rssFeedPath: Path? = .defaultForRSSFeed, + rssFeedTitle: String? = nil + ) -> Node { + var title = location.title + + if let tagDetail = location as? TagDetailsPage { + title = tagDetail.title + } + + if title.isEmpty { + title = site.name + } else { + title.append(titleSeparator + site.name) + } + + var description = location.description + + if description.isEmpty { + description = site.description + } + + let localizedRssFeedPath = rssFeedPath == nil ? nil : Path(site.pathPrefix(for: location.language!)).appendingComponent(rssFeedPath!.string) + return .head( + .encoding(.utf8), + .siteName(site.name), + .url(site.url(for: location)), + .title(title), + .description(description), + .twitterCardType(location.imagePath == nil ? .summary : .summaryLargeImage), + .forEach(stylesheetPaths, { .stylesheet($0) }), + .viewport(.accordingToDevice), + .unwrap(site.favicon, { .favicon($0) }), + .unwrap(localizedRssFeedPath, { path in + let title = rssFeedTitle ?? "Subscribe to \(site.name)" + return .rssFeedLink(path.absoluteString, title: title) + }), + .unwrap(location.imagePath ?? site.imagePath, { path in + let url = site.url(for: path) + return .socialImageLink(url) + }) + ) + } +} + + + diff --git a/Sources/Publish/API/PublishingContext.swift b/Sources/Publish/API/PublishingContext.swift index 4dd14caa..ec814af2 100644 --- a/Sources/Publish/API/PublishingContext.swift +++ b/Sources/Publish/API/PublishingContext.swift @@ -379,3 +379,123 @@ private extension PublishingContext { } } } + +public extension PublishingContext where Site: MultiLanguageWebsite { + func section(_ id: Site.SectionID, in language: Language) -> Section { + if let section = MultiLanguageContentManager.location(at: Path(id.rawValue), in: language, for: self.site) as? Section { + return section + } + var dummySection = Section(id: id) + dummySection.language = language + self.add(dummySection) + return dummySection + } + + /// Return all items within this website, sorted by a given key path. + /// - parameter sortingKeyPath: The key path to sort the items by. + /// - parameter order: The order to use when sorting the items. + func allItems( + sortedBy sortingKeyPath: KeyPath, T>, + in language: Language, + order: SortOrder = .ascending + ) -> [Item] { + let items = sections.flatMap { $0.items(in: language) } + return items.sorted( + by: order.makeSorter(forKeyPath: sortingKeyPath) + ) + } + + /// Return all items that were tagged with a given tag. + /// - parameter tag: The tag to return all items for. + /// - parameter language: The language to return all items for. + func items(taggedWith tag: Tag, in language: Language) -> [Item] { + sections.flatMap { $0.items(taggedWith: tag).filter{ $0.language == language } } + } + + /// Return all items that were tagged with a given tag, sorted by + /// a given key path. + /// - parameter tag: The tag to return all items for. + /// - parameter sortingKeyPath: The key path to sort the items by. + /// - parameter order: The order to use when sorting the items. + func items( + taggedWith tag: Tag, + sortedBy sortingKeyPath: KeyPath, T>, + in language: Language, + order: SortOrder = .ascending + ) -> [Item] { + let a = items(taggedWith: tag, in: language).sorted( + by: order.makeSorter(forKeyPath: sortingKeyPath) + ) + return a + } + + /// Return all items that were tagged with a given tag. + /// - parameter language: The language to return all items for. + func items(in language: Language) -> [Item] { + sections.flatMap { $0.items.filter{ $0.language == language } } + } + + /// Return all tags that has items in a language. + /// - parameter language: The language to return all tags for. + func allTags(in language: Language) -> Set { + self.allTags.filter{tag in self.items(taggedWith: tag, in: language).count > 0 } + } + + /// Index page localized in language. + /// - parameter language: The language to return index for. + func index(in language: Language) -> Index { + if let index = MultiLanguageContentManager.location(at: "", in: language, for: self.site) as? Index { + return index + } + // make dummy index + var dummyIndex = Index() + dummyIndex.language = language + self.add(dummyIndex) + return dummyIndex + } + + func alternateLinks(for location: Location) -> [Language: Path] { + MultiLanguageContentManager.alternateLinks(for: location, in: self) + } + + var index: Index { + get { + index(in: self.site.language) + } + set { + var index = newValue + index.language = site.language + add(index) + } + } + + /// Add a page to the website programmatically. + /// - parameter page: The page to add. + mutating func addPage(_ page: Page) { + pages[Path(site.pathPrefix(for: page.language!)).appendingComponent(page.path.string)] = page + add(page) + } +} + +internal extension PublishingContext where Site: MultiLanguageWebsite { + func add(_ location: Location) { + MultiLanguageContentManager.register( + location, + for: self.site + ) + if let item = location as? Item { + item.tags.forEach { tag in + var detailsPage = TagDetailsPage( + tag: tag, + path: site.path(for: tag), + content: .init() + ) + detailsPage.language = item.language! + MultiLanguageContentManager.register( + detailsPage, + for: self.site + ) + } + } + } +} diff --git a/Sources/Publish/API/PublishingStep.swift b/Sources/Publish/API/PublishingStep.swift index ecd19dbe..c5120860 100644 --- a/Sources/Publish/API/PublishingStep.swift +++ b/Sources/Publish/API/PublishingStep.swift @@ -466,3 +466,101 @@ private extension PublishingStep { ) } } + +public extension PublishingStep where Site: MultiLanguageWebsite { + /// Parse a folder of Markdown files and use them to add content to + /// the website. The root folders will be parsed as sections, and the + /// files within them as items, while root files will be parsed as pages. + /// - parameter path: The path of the Markdown folder to add (default: `Content`). + static func addMarkdownFiles(at path: Path = "Content") -> Self { + step(named: "Add Markdown files from '\(path)' folder") { context in + let folder = try context.folder(at: path) + try MarkdownFileHandler().addMarkdownFiles(in: folder, to: &context) + } + } + + /// Parse the folders of Markdown files in different languages and use them to add content to + /// the website. The root folders will be parsed as sections, and the + /// files within them as items, while root files will be parsed as pages. + static func addMarkdownFiles() -> Self { + step(named: "Add Markdown files from folder of each language") { context in + try context.site.languages.forEach { language in + let folder = try context.folder(at: Path(context.site.contentFolder(for: language))) + try MarkdownFileHandler().addMarkdownFiles(in: folder, to: &context, in: language) + } + } + } + + /// Generate the website's HTML using a given theme. + /// - parameter theme: The theme to use to generate the website's HTML. + /// - parameter indentation: How each HTML file should be indented. + /// - parameter fileMode: The mode to use when generating each HTML file. + static func generateHTML( + withTheme theme: Theme, + indentation: Indentation.Kind? = nil, + fileMode: HTMLFileMode = .foldersAndIndexFiles + ) -> Self { + step(named: "Generate HTML") { context in + let generator = HTMLGenerator( + theme: theme, + indentation: indentation, + fileMode: fileMode, + context: context + ) + try generator.generate() + } + } + + /// Generate an RSS feed for the website. + /// - parameter includedSectionIDs: The IDs of the sections which items + /// to include when generating the feed. + /// - parameter itemPredicate: A predicate used to determine whether to + /// include a given item within the generated feed (default: include all). + /// - parameter config: The configuration to use when generating the feed. + /// - parameter date: The date that should act as the build and publishing + /// date for the generated feed (default: the current date). + static func generateRSSFeed( + including includedSectionIDs: Set, + itemPredicate: Predicate>? = nil, + config: RSSFeedConfiguration = .default, + date: Date = Date() + ) -> Self { + guard !includedSectionIDs.isEmpty else { return .empty } + + return step(named: "Generate RSS feed") { context in + let generator = RSSFeedGenerator( + includedSectionIDs: includedSectionIDs, + itemPredicate: itemPredicate, + config: config, + context: context, + date: date + ) + try generator.generate() + } + } + + /// Generate a site map for the website, which is an XML file used + /// for search engine indexing. + /// - parameter excludedPaths: Any paths to exclude from the site map. + /// Adding a section's path to the list removes the entire section, including all its items. + /// - parameter indentation: How the site map should be indented. + static func generateSiteMap(excluding excludedPaths: Set = [], + indentedBy indentation: Indentation.Kind? = nil) -> Self { + step(named: "Generate site map") { context in + let generator = SiteMapGenerator( + excludedPaths: excludedPaths, + indentation: indentation, + context: context + ) + + try generator.generate() + } + } + + /// Copy the index.html of default language to root path of output folder. + static func copyDefaultIndexHtml() -> Self { + step(named: "Copy index.html of default language", body: { context in + try context.outputFile(at: "\(context.site.pathPrefix(for: context.site.language))/index.html").copy(to: context.outputFolder(at: "")) + }) + } +} diff --git a/Sources/Publish/API/Section.swift b/Sources/Publish/API/Section.swift index 75e6c76d..dd05085c 100644 --- a/Sources/Publish/API/Section.swift +++ b/Sources/Publish/API/Section.swift @@ -5,6 +5,7 @@ */ import Foundation +import Plot /// Type representing one of a website's top sections, as defined by /// its `SectionID` type. Each section can contain content of its own, @@ -181,3 +182,9 @@ private extension Section { } } } + +public extension Section where Site: MultiLanguageWebsite { + func items(in language: Language) -> [Item] { + self.items.filter { $0.language == language } + } +} diff --git a/Sources/Publish/Internal/HTMLGenerator.swift b/Sources/Publish/Internal/HTMLGenerator.swift index b9c69baa..cb989fff 100644 --- a/Sources/Publish/Internal/HTMLGenerator.swift +++ b/Sources/Publish/Internal/HTMLGenerator.swift @@ -143,3 +143,125 @@ private extension HTMLGenerator { } } } + +internal extension HTMLGenerator where Site: MultiLanguageWebsite { + func generate() throws { + try copyThemeResources() + try generateIndexHTML() + try generateSectionHTML() + try generatePageHTML() + try generateTagHTMLIfNeeded() + } +} + +private extension HTMLGenerator where Site: MultiLanguageWebsite { + func generateIndexHTML() throws { + try context.site.languages.forEach { (language) in + let index = context.index(in: language) + let html = try theme.makeIndexHTML(index, context) + let indexFile = try context.createOutputFile(at: context.site.path(for: index)) + try indexFile.write(html.render(indentedBy: indentation)) + } + } + + func generateSectionHTML() throws { + for section in context.sections { + try context.site.languages.forEach { language in + let localizedSection = context.section(section.id, in: language) + + try outputHTML( + for: localizedSection, + indentedBy: indentation, + using: theme.makeSectionHTML, + fileMode: .foldersAndIndexFiles + ) + } + for item in section.items { + try outputHTML( + for: item, + indentedBy: indentation, + using: theme.makeItemHTML, + fileMode: fileMode + ) + } + } + } + + func generatePageHTML() throws { + for page in context.pages.values { + try outputHTML( + for: page, + indentedBy: indentation, + using: theme.makePageHTML, + fileMode: fileMode + ) + } + } + + func generateTagHTMLIfNeeded() throws { + guard let config = context.site.tagHTMLConfig else { + return + } + + try context.site.languages.forEach { language in + let pathPrefix = context.site.pathPrefix(for: language) + let allTags = context.allTags(in: language) + var listPage = TagListPage( + tags: allTags, + path: config.basePath, + content: config.listContent ?? .init() + ) + listPage.language = language + + if let listHTML = try theme.makeTagListHTML(listPage, context) { + let listPath = Path("\(pathPrefix)/\(config.basePath)/index.html") + let listFile = try context.createOutputFile(at: listPath) + try listFile.write(listHTML.render(indentedBy: indentation)) + } + for tag in allTags { + let detailsPath = context.site.path(for: tag) + let detailsContent = config.detailsContentResolver(tag) + + var detailsPage = TagDetailsPage( + tag: tag, + path: detailsPath, + content: detailsContent ?? .init() + ) + detailsPage.language = language + + guard let detailsHTML = try theme.makeTagDetailsHTML(detailsPage, context) else { + continue + } + + try outputHTML( + for: detailsPage, + indentedBy: indentation, + using: { _, _ in detailsHTML }, + fileMode: fileMode + ) + } + } + } + + func outputHTML( + for location: T, + indentedBy indentation: Indentation.Kind?, + using generator: (T, PublishingContext) throws -> HTML, + fileMode: HTMLFileMode + ) throws { + let html = try generator(location, context) + let path = filePath(for: location, fileMode: fileMode) + let file = try context.createOutputFile(at: path) + try file.write(html.render(indentedBy: indentation)) + } + + func filePath(for location: Location, fileMode: HTMLFileMode) -> Path { + let pathPrefix = context.site.pathPrefix(for: location.language!) + switch fileMode { + case .foldersAndIndexFiles: + return "\(pathPrefix)/\(location.path)/index.html" + case .standAloneFiles: + return "\(pathPrefix)/\(location.path).html" + } + } +} diff --git a/Sources/Publish/Internal/MarkdownContentFactory.swift b/Sources/Publish/Internal/MarkdownContentFactory.swift index c6b461fb..4f7a7b51 100644 --- a/Sources/Publish/Internal/MarkdownContentFactory.swift +++ b/Sources/Publish/Internal/MarkdownContentFactory.swift @@ -8,6 +8,9 @@ import Foundation import Ink import Files import Codextended +import Plot + +extension Language: Decodable {} internal struct MarkdownContentFactory { let parser: MarkdownParser @@ -67,13 +70,14 @@ private extension MarkdownContentFactory { let imagePath = try decoder.decodeIfPresent("image", as: Path.self) let audio = try decoder.decodeIfPresent("audio", as: Audio.self) let video = try decoder.decodeIfPresent("video", as: Video.self) - + let language = try decoder.decodeIfPresent("language", as: Language.self) return Content( title: title ?? markdown.title ?? file.nameExcludingExtension, description: description ?? "", body: Content.Body(html: markdown.html), date: date, lastModified: lastModified, + language: language, imagePath: imagePath, audio: audio, video: video diff --git a/Sources/Publish/Internal/MarkdownFileHandler.swift b/Sources/Publish/Internal/MarkdownFileHandler.swift index 14dd36f9..70985731 100644 --- a/Sources/Publish/Internal/MarkdownFileHandler.swift +++ b/Sources/Publish/Internal/MarkdownFileHandler.swift @@ -161,3 +161,138 @@ private extension File { self.extension.map(File.markdownFileExtensions.contains) ?? false } } + +import Plot + +internal extension MarkdownFileHandler where Site: MultiLanguageWebsite { + func addMarkdownFiles( + in folder: Folder, + to context: inout PublishingContext, + in language: Language? = nil + ) throws { + let factory = context.makeMarkdownContentFactory() + + if let indexFile = try? folder.file(named: "index.md") { + do { + var content = try factory.makeContent(fromFile: indexFile) + if content.language == nil { + content.language = language + } + context.add(Index(content: content)) + } catch { + throw wrap(error, forPath: "\(folder.path)index.md") + } + } + + for subfolder in folder.subfolders { + guard let sectionID = Site.SectionID(rawValue: subfolder.name.lowercased()) else { + try addPagesForMarkdownFiles( + inFolder: subfolder, + to: &context, + recursively: true, + parentPath: Path(subfolder.name), + factory: factory, + in: language + ) + continue + } + + for file in subfolder.files.recursive { + guard file.isMarkdown else { continue } + + if file.nameExcludingExtension == "index", file.parent == subfolder { + let content = try factory.makeContent(fromFile: file) + + var localizedSection = Section(id: sectionID) + localizedSection.content = content + if localizedSection.language == nil { + localizedSection.language = language + } + context.add(localizedSection) + continue + } + + do { + let fileName = file.nameExcludingExtension + let path: Path + + if let parentPath = file.parent?.path(relativeTo: subfolder) { + path = Path(parentPath).appendingComponent(fileName) + } else { + path = Path(fileName) + } + + var item = try factory.makeItem( + fromFile: file, + at: path, + sectionID: sectionID + ) + if item.language == nil { + item.language = language + } + + context.addItem(item) + context.add(item) + } catch { + let path = Path(file.path(relativeTo: folder)) + throw wrap(error, forPath: path) + } + } + } + + try addPagesForMarkdownFiles( + inFolder: folder, + to: &context, + recursively: false, + parentPath: "", + factory: factory, + in: language + ) + } +} + +private extension MarkdownFileHandler where Site: MultiLanguageWebsite { + func addPagesForMarkdownFiles( + inFolder folder: Folder, + to context: inout PublishingContext, + recursively: Bool, + parentPath: Path, + factory: MarkdownContentFactory, + in language: Language? = nil + ) throws { + for file in folder.files { + guard file.isMarkdown else { continue } + + if file.nameExcludingExtension == "index", !recursively { + continue + } + + let pagePath = parentPath.appendingComponent(file.nameExcludingExtension) + var page = try factory.makePage(fromFile: file, at: pagePath) + + if page.language == nil { + page.language = language + } + + context.addPage(page) + + } + + guard recursively else { + return + } + + for subfolder in folder.subfolders { + let parentPath = parentPath.appendingComponent(subfolder.name) + + try addPagesForMarkdownFiles( + inFolder: subfolder, + to: &context, + recursively: true, + parentPath: parentPath, + factory: factory, + in: language + ) + } + } +} diff --git a/Sources/Publish/Internal/MultiLanguageContentManager.swift b/Sources/Publish/Internal/MultiLanguageContentManager.swift new file mode 100644 index 00000000..6c70532c --- /dev/null +++ b/Sources/Publish/Internal/MultiLanguageContentManager.swift @@ -0,0 +1,48 @@ +import Foundation +import Plot + +fileprivate typealias SiteDescription = String + +internal struct MultiLanguageContentManager { + private static var multiLanguageContents: [SiteDescription: [String: [Language: Location]]] = [:] + + internal static func register(_ location: Location, for site: T) { + multiLanguageContents[ + String(describing: site), + default: [:] + ][ + (location as? Item)?.metadata.alternateLinkIdentifier ?? location.path.string, + default: [:] + ][ + location.language! + ] = location + } + + internal static func location(at path: Path, in language: Language, for site: T) -> Location? { + multiLanguageContents[String(describing: site)]?[path.string]?[language] + } + + internal static func alternateLinks(for location: Location, in context: PublishingContext) -> [Language: Path] { + + if let _ = location as? TagListPage { + return Dictionary(uniqueKeysWithValues: context.site.languages.map({ + ($0, "/\(context.site.tagListPath(in: $0))") + })) + } + + let locationKey = (location as? Item)?.metadata.alternateLinkIdentifier ?? location.path.string + + let locations = multiLanguageContents[ + String(describing: context.site), + default: [:] + ][ + locationKey, + default: [:] + ] + + return Dictionary(uniqueKeysWithValues: locations.keys.map({ + ($0, context.site.path(for: locations[$0]!)) + })) + } +} + diff --git a/Sources/Publish/Internal/RSSFeedGenerator.swift b/Sources/Publish/Internal/RSSFeedGenerator.swift index d6a2e1d6..04a663f6 100644 --- a/Sources/Publish/Internal/RSSFeedGenerator.swift +++ b/Sources/Publish/Internal/RSSFeedGenerator.swift @@ -79,3 +79,52 @@ private extension RSSFeedGenerator { ) } } + +internal extension RSSFeedGenerator where Site: MultiLanguageWebsite { + func generate() throws { + try context.site.languages.forEach{ language in + let outputFile = try context.createOutputFile(at: "\(context.site.pathPrefix(for: language))/\(config.targetPath)") + var items = [Item]() + + for sectionID in includedSectionIDs { + items += context.sections[sectionID].items(in: language) + } + + items.sort { $0.date > $1.date } + + if let predicate = itemPredicate?.inverse() { + items.removeAll(where: predicate.matches) + } + + let feed = makeFeed(containing: items, in: language).render(indentedBy: config.indentation) + + try outputFile.write(feed) + } + } +} + +private extension RSSFeedGenerator where Site: MultiLanguageWebsite { + func makeFeed(containing items: [Item], in language: Language) -> RSS { + RSS( + .title(context.site.name), + .description(context.site.description), + .link(context.site.url), + .language(language), + .lastBuildDate(date, timeZone: context.dateFormatter.timeZone), + .pubDate(date, timeZone: context.dateFormatter.timeZone), + .ttl(Int(config.ttlInterval)), + .atomLink(context.site.url(for: config.targetPath)), + .forEach(items.prefix(config.maximumItemCount)) { item in + .item( + .guid(for: item, site: context.site), + .title(item.rssTitle), + .description(item.description), + .link(item.rssProperties.link ?? context.site.url(for: item)), + //.link(item.rssProperties.link ?? context.site.url(for: item, in: language)), + .pubDate(item.date, timeZone: context.dateFormatter.timeZone), + .content(for: item, site: context.site) + ) + } + ) + } +} diff --git a/Sources/Publish/Internal/SiteMapGenerator.swift b/Sources/Publish/Internal/SiteMapGenerator.swift index b196c7d4..813f06a7 100644 --- a/Sources/Publish/Internal/SiteMapGenerator.swift +++ b/Sources/Publish/Internal/SiteMapGenerator.swift @@ -74,3 +74,70 @@ private extension SiteMapGenerator { ) } } + +internal extension SiteMapGenerator where Site: MultiLanguageWebsite { + func generate() throws { + let sections = context.sections.sorted { + $0.id.rawValue < $1.id.rawValue + } + + let pages = context.pages.values.sorted { + $0.path < $1.path + } + + let siteMap = makeSiteMap(for: sections, pages: pages, site: context.site) + let xml = siteMap.render(indentedBy: indentation) + let file = try context.createOutputFile(at: "sitemap.xml") + try file.write(xml) + } +} + +private extension SiteMapGenerator where Site: MultiLanguageWebsite { + func makeSiteMap(for sections: [Section], pages: [Page], site: Site) -> SiteMap { + SiteMap( + .forEach(sections) { section in + guard !excludedPaths.contains(section.path) else { + return .empty + } + + return .group( + .forEach(site.languages) { language in + .url( + .loc(site.url(for: context.section(section.id, in: language))), + .changefreq(.daily), + .priority(1.0), + .lastmod(max( + section.lastModified, + section.lastItemModificationDate ?? .distantPast + )) + ) + }, + .forEach(section.items) { item in + guard !excludedPaths.contains(item.path) else { + return .empty + } + + return .url( + .loc(site.url(for: item)), + .changefreq(.monthly), + .priority(0.5), + .lastmod(item.lastModified) + ) + } + ) + }, + .forEach(pages) { page in + guard !excludedPaths.contains(page.path) else { + return .empty + } + + return .url( + .loc(site.url(for: page)), + .changefreq(.monthly), + .priority(0.5), + .lastmod(page.lastModified) + ) + } + ) + } +} diff --git a/Tests/PublishTests/Tests/MarkdownTests.swift b/Tests/PublishTests/Tests/MarkdownTests.swift index 10fdd9bf..465f0e1b 100644 --- a/Tests/PublishTests/Tests/MarkdownTests.swift +++ b/Tests/PublishTests/Tests/MarkdownTests.swift @@ -54,6 +54,7 @@ final class MarkdownTests: PublishTestCase { tags: One, Two, Three image: myImage.png date: 2019-12-14 10:30 + language: en audio.url: https://myFile.mp3 audio.duration: 01:03:05 video.youTube: 12345 @@ -72,6 +73,7 @@ final class MarkdownTests: PublishTestCase { XCTAssertEqual(item.tags, ["One", "Two", "Three"]) XCTAssertEqual(item.imagePath, "myImage.png") XCTAssertEqual(item.date, expectedDateComponents.date) + XCTAssertEqual(item.language!.rawValue, "en") XCTAssertEqual(item.audio?.url, URL(string: "https://myFile.mp3")) XCTAssertEqual(item.audio?.duration, Audio.Duration(hours: 1, minutes: 3, seconds: 5)) XCTAssertEqual(item.video, .youTube(id: "12345"))