Repository: iamgio/quarkdown Branch: main Commit: 007e056b4dc1 Files: 1523 Total size: 3.3 MB Directory structure: gitextract_3z3aa1yn/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── enhancement.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ ├── bump-scoop/ │ │ │ └── action.yml │ │ ├── deploy-wiki/ │ │ │ └── action.yml │ │ ├── setup-environment/ │ │ │ └── action.yml │ │ └── update-emoji-list/ │ │ ├── action.yml │ │ └── generate.qd │ └── workflows/ │ ├── deploy-wiki.yml │ ├── generate-pdf/ │ │ ├── color.txt │ │ ├── generate-theme-combinations.js │ │ └── layout.txt │ ├── generate-pdf.yml │ ├── gradle-deploy.yml │ ├── gradle-test.yml │ └── update-emoji-list.yml ├── .gitignore ├── .run/ │ ├── CLI_Docs.run.xml │ ├── CLI_Mock.run.xml │ ├── Lint_format.run.xml │ ├── Test_full_suite.run.xml │ └── Test_minimal_suite.run.xml ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── build.gradle.kts ├── demo/ │ ├── code/ │ │ └── Point.java │ ├── csv/ │ │ └── people.csv │ ├── demo.qd │ ├── mermaid/ │ │ └── flow.mmd │ └── sources/ │ └── other.qd ├── docs/ │ ├── _nav.qd │ ├── _setup.qd │ ├── align.qd │ ├── assets/ │ │ ├── analytics.html │ │ ├── people.csv │ │ ├── people2.csv │ │ ├── point.ts │ │ ├── sales.csv │ │ └── style.css │ ├── bibliography/ │ │ ├── file.bib │ │ └── source.qd │ ├── bibliography.qd │ ├── boolean.qd │ ├── box.qd │ ├── caption-position.qd │ ├── cli-compiler.qd │ ├── cli-project-creator.qd │ ├── cli-webserver.qd │ ├── clip.qd │ ├── code-caption.qd │ ├── code.qd │ ├── collapsible.qd │ ├── color.qd │ ├── conditional-statements.qd │ ├── container.qd │ ├── cross-references.qd │ ├── css.qd │ ├── custom-figure.qd │ ├── declaring-functions.qd │ ├── destructuring.qd │ ├── dictionary.qd │ ├── docs-library.qd │ ├── document-metadata.qd │ ├── document-types.qd │ ├── emojis.qd │ ├── enumeration-entry.qd │ ├── figure.qd │ ├── file-data.qd │ ├── file-tree.qd │ ├── float.qd │ ├── font-configuration.qd │ ├── footnotes.qd │ ├── headings.qd │ ├── html.qd │ ├── icons.qd │ ├── image-size.qd │ ├── importing-external-libraries.qd │ ├── including-other-quarkdown-files.qd │ ├── inclusion-vs-subdocuments.qd │ ├── inside-live-preview.qd │ ├── iterable.qd │ ├── lambda.qd │ ├── landscape-content.qd │ ├── let.qd │ ├── line-breaks.qd │ ├── localization.qd │ ├── logging.qd │ ├── loops.qd │ ├── main.qd │ ├── markdown-content.qd │ ├── math.qd │ ├── media-storage.qd │ ├── mermaid-diagrams.qd │ ├── multi-column-layout/ │ │ └── source.qd │ ├── multi-column-layout.qd │ ├── none.qd │ ├── numbering.qd │ ├── page-break.qd │ ├── page-counter.qd │ ├── page-format.qd │ ├── page-margin-content.qd │ ├── paper-library.qd │ ├── paragraph-style.qd │ ├── pdf-export.qd │ ├── persistent-headings.qd │ ├── pipeline---function-call-expansion.qd │ ├── pipeline---lexing.qd │ ├── pipeline---parsing.qd │ ├── pipeline---post-rendering.qd │ ├── pipeline---rendering.qd │ ├── pipeline---tree-traversal.qd │ ├── pipeline.qd │ ├── quickstart.qd │ ├── quotation-source.qd │ ├── quote-types.qd │ ├── range.qd │ ├── sizes.qd │ ├── slides-configuration.qd │ ├── slides-fragment.qd │ ├── slides-speaker-notes.qd │ ├── stacks.qd │ ├── subdocuments.qd │ ├── syntax-of-a-function-call.qd │ ├── table-caption.qd │ ├── table-generation.qd │ ├── table-manipulation.qd │ ├── table-of-contents.qd │ ├── tex-formulae.qd │ ├── tex-macros.qd │ ├── text-symbols.qd │ ├── text.qd │ ├── themes.qd │ ├── typing.qd │ ├── variables.qd │ ├── whitespace.qd │ └── xy-chart.qd ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── mock/ │ ├── README.md │ ├── alignment.qd │ ├── bibliography/ │ │ └── bibliography.bib │ ├── bibliography.qd │ ├── boxes.qd │ ├── code/ │ │ └── Wrapper.java │ ├── code.qd │ ├── collapsibles.qd │ ├── colorpreview.qd │ ├── crossreferences.qd │ ├── errors.qd │ ├── footnotes.qd │ ├── headings.qd │ ├── icons.qd │ ├── images.qd │ ├── lists.qd │ ├── localization.qd │ ├── main.qd │ ├── math.qd │ ├── mermaid/ │ │ ├── class.mmd │ │ ├── flow.mmd │ │ ├── git.mmd │ │ ├── pie.mmd │ │ └── sequence.mmd │ ├── mermaid.qd │ ├── paragraphs.qd │ ├── quotes.qd │ ├── separators.qd │ ├── setup.qd │ ├── stacks.qd │ ├── tables.qd │ └── textformatting.qd ├── quarkdown-cli/ │ ├── LICENSE │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── cli/ │ │ │ ├── CliOptions.kt │ │ │ ├── PipelineInitialization.kt │ │ │ ├── QuarkdownCli.kt │ │ │ ├── creator/ │ │ │ │ ├── ProjectCreator.kt │ │ │ │ ├── command/ │ │ │ │ │ └── CreateProjectCommand.kt │ │ │ │ ├── content/ │ │ │ │ │ ├── DefaultProjectCreatorInitialContentSupplier.kt │ │ │ │ │ ├── DefaultTheme.kt │ │ │ │ │ ├── DocsProjectCreatorInitialContentSupplier.kt │ │ │ │ │ ├── EmptyProjectCreatorInitialContentSupplier.kt │ │ │ │ │ └── ProjectCreatorInitialContentSupplier.kt │ │ │ │ └── template/ │ │ │ │ ├── DefaultProjectCreatorTemplateProcessorFactory.kt │ │ │ │ ├── DocsProjectCreatorTemplateProcessorFactory.kt │ │ │ │ ├── ProjectCreatorTemplatePlaceholders.kt │ │ │ │ └── ProjectCreatorTemplateProcessorFactory.kt │ │ │ ├── exec/ │ │ │ │ ├── CompileCommand.kt │ │ │ │ ├── Execute.kt │ │ │ │ ├── ExecuteCommand.kt │ │ │ │ ├── ExecutionOutcome.kt │ │ │ │ ├── ReplCommand.kt │ │ │ │ └── strategy/ │ │ │ │ ├── FileExecutionStrategy.kt │ │ │ │ ├── PipelineExecutionStrategy.kt │ │ │ │ └── ReplExecutionStrategy.kt │ │ │ ├── lib/ │ │ │ │ └── QdLibraries.kt │ │ │ ├── lsp/ │ │ │ │ └── LanguageServerCommand.kt │ │ │ ├── renderer/ │ │ │ │ └── RendererRetriever.kt │ │ │ ├── server/ │ │ │ │ ├── BrowserLauncherOption.kt │ │ │ │ ├── StartWebServerCommand.kt │ │ │ │ ├── WebServerOptions.kt │ │ │ │ └── WebServerStarter.kt │ │ │ ├── util/ │ │ │ │ ├── IOUtils.kt │ │ │ │ └── MillisStopwatch.kt │ │ │ └── watcher/ │ │ │ └── DirectoryWatcher.kt │ │ └── resources/ │ │ └── creator/ │ │ ├── docs/ │ │ │ ├── _nav.qd │ │ │ ├── main.qd.jte │ │ │ ├── page-1.qd │ │ │ ├── page-2.qd │ │ │ └── page-3.qd │ │ ├── initialcontent.qd.jte │ │ └── main.qd.jte │ └── test/ │ └── kotlin/ │ └── com/ │ └── quarkdown/ │ └── cli/ │ ├── BrowserLauncherSelectionTest.kt │ ├── CompileCommandTest.kt │ ├── ProjectCreatorCommandTest.kt │ ├── ProjectCreatorTest.kt │ ├── TempDirectory.kt │ ├── VersionTest.kt │ └── WatcherTest.kt ├── quarkdown-core/ │ ├── build.gradle.kts │ ├── csl-styles.txt │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── core/ │ │ │ ├── ExitCodes.kt │ │ │ ├── ast/ │ │ │ │ ├── AstRoot.kt │ │ │ │ ├── InlineContent.kt │ │ │ │ ├── MarkdownContent.kt │ │ │ │ ├── Node.kt │ │ │ │ ├── attributes/ │ │ │ │ │ ├── AstAttributes.kt │ │ │ │ │ ├── id/ │ │ │ │ │ │ ├── Identifiable.kt │ │ │ │ │ │ └── IdentifierProvider.kt │ │ │ │ │ ├── link/ │ │ │ │ │ │ └── ResolvedLinkUrlProperty.kt │ │ │ │ │ ├── localization/ │ │ │ │ │ │ ├── LocalizedKind.kt │ │ │ │ │ │ └── LocalizedKindKeys.kt │ │ │ │ │ ├── location/ │ │ │ │ │ │ ├── LocationLabelProperty.kt │ │ │ │ │ │ ├── LocationTrackableNode.kt │ │ │ │ │ │ ├── SectionLocation.kt │ │ │ │ │ │ └── SectionLocationProperty.kt │ │ │ │ │ ├── presence/ │ │ │ │ │ │ ├── CodePresenceProperty.kt │ │ │ │ │ │ ├── MathPresenceProperty.kt │ │ │ │ │ │ ├── MermaidPresenceProperty.kt │ │ │ │ │ │ └── ThirdPartyPresenceProperties.kt │ │ │ │ │ └── reference/ │ │ │ │ │ ├── ReferenceNode.kt │ │ │ │ │ └── ResolvedReferenceProperty.kt │ │ │ │ ├── base/ │ │ │ │ │ ├── LinkNode.kt │ │ │ │ │ ├── TextNode.kt │ │ │ │ │ ├── block/ │ │ │ │ │ │ ├── BlankNode.kt │ │ │ │ │ │ ├── BlockQuote.kt │ │ │ │ │ │ ├── Code.kt │ │ │ │ │ │ ├── FootnoteDefinition.kt │ │ │ │ │ │ ├── Heading.kt │ │ │ │ │ │ ├── HeadingFactory.kt │ │ │ │ │ │ ├── HeadingMarker.kt │ │ │ │ │ │ ├── HorizontalRule.kt │ │ │ │ │ │ ├── Html.kt │ │ │ │ │ │ ├── LinkDefinition.kt │ │ │ │ │ │ ├── Newline.kt │ │ │ │ │ │ ├── Paragraph.kt │ │ │ │ │ │ ├── Table.kt │ │ │ │ │ │ └── list/ │ │ │ │ │ │ ├── List.kt │ │ │ │ │ │ ├── ListItem.kt │ │ │ │ │ │ ├── ListItemVariant.kt │ │ │ │ │ │ └── TaskListItemVariant.kt │ │ │ │ │ └── inline/ │ │ │ │ │ ├── CheckBox.kt │ │ │ │ │ ├── CodeSpan.kt │ │ │ │ │ ├── Comment.kt │ │ │ │ │ ├── Emphasis.kt │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── LineBreak.kt │ │ │ │ │ ├── Link.kt │ │ │ │ │ ├── ReferenceFootnote.kt │ │ │ │ │ ├── SubdocumentLink.kt │ │ │ │ │ └── Text.kt │ │ │ │ ├── dsl/ │ │ │ │ │ ├── AstBuilder.kt │ │ │ │ │ ├── BlockAstBuilder.kt │ │ │ │ │ ├── InlineAstBuilder.kt │ │ │ │ │ ├── ListAstBuilder.kt │ │ │ │ │ └── TableAstBuilder.kt │ │ │ │ ├── iterator/ │ │ │ │ │ ├── AstIterator.kt │ │ │ │ │ ├── AstIteratorHook.kt │ │ │ │ │ └── ObservableAstIterator.kt │ │ │ │ ├── media/ │ │ │ │ │ └── StoredMediaProperty.kt │ │ │ │ └── quarkdown/ │ │ │ │ ├── CaptionableNode.kt │ │ │ │ ├── FunctionCallNode.kt │ │ │ │ ├── bibliography/ │ │ │ │ │ ├── BibliographyCitation.kt │ │ │ │ │ └── BibliographyView.kt │ │ │ │ ├── block/ │ │ │ │ │ ├── Box.kt │ │ │ │ │ ├── Clipped.kt │ │ │ │ │ ├── Collapse.kt │ │ │ │ │ ├── Container.kt │ │ │ │ │ ├── Figure.kt │ │ │ │ │ ├── FileTree.kt │ │ │ │ │ ├── Landscape.kt │ │ │ │ │ ├── Math.kt │ │ │ │ │ ├── MermaidDiagram.kt │ │ │ │ │ ├── NavigationContainer.kt │ │ │ │ │ ├── Numbered.kt │ │ │ │ │ ├── PageBreak.kt │ │ │ │ │ ├── SlidesFragment.kt │ │ │ │ │ ├── SlidesSpeakerNote.kt │ │ │ │ │ ├── Stacked.kt │ │ │ │ │ ├── SubdocumentGraph.kt │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── FocusListItemVariant.kt │ │ │ │ │ │ ├── LocationTargetListItemVariant.kt │ │ │ │ │ │ └── TableOfContentsItemVariant.kt │ │ │ │ │ └── toc/ │ │ │ │ │ ├── TableOfContentsUtils.kt │ │ │ │ │ └── TableOfContentsView.kt │ │ │ │ ├── inline/ │ │ │ │ │ ├── IconImage.kt │ │ │ │ │ ├── InlineCollapse.kt │ │ │ │ │ ├── LastHeading.kt │ │ │ │ │ ├── MathSpan.kt │ │ │ │ │ ├── PageCounter.kt │ │ │ │ │ ├── TextSymbol.kt │ │ │ │ │ ├── TextTransform.kt │ │ │ │ │ └── Whitespace.kt │ │ │ │ ├── invisible/ │ │ │ │ │ ├── PageMarginContentInitializer.kt │ │ │ │ │ ├── PageNumberFormatter.kt │ │ │ │ │ ├── PageNumberReset.kt │ │ │ │ │ └── SlidesConfigurationInitializer.kt │ │ │ │ └── reference/ │ │ │ │ ├── CrossReference.kt │ │ │ │ └── CrossReferenceableNode.kt │ │ │ ├── bibliography/ │ │ │ │ ├── Bibliography.kt │ │ │ │ └── style/ │ │ │ │ ├── BibliographyEntryLabelProviderStrategy.kt │ │ │ │ ├── BibliographyStyle.kt │ │ │ │ └── csl/ │ │ │ │ ├── CslBibliographyStyle.kt │ │ │ │ ├── CslTokenConverter.kt │ │ │ │ ├── FormattedBibliographyEntry.kt │ │ │ │ └── QuarkdownCslFormat.kt │ │ │ ├── context/ │ │ │ │ ├── BaseContext.kt │ │ │ │ ├── ChildContext.kt │ │ │ │ ├── Context.kt │ │ │ │ ├── ContextOptions.kt │ │ │ │ ├── MutableContext.kt │ │ │ │ ├── ScopeContext.kt │ │ │ │ ├── SharedContext.kt │ │ │ │ ├── SubdocumentContext.kt │ │ │ │ ├── file/ │ │ │ │ │ └── FileSystem.kt │ │ │ │ ├── hooks/ │ │ │ │ │ ├── LinkUrlResolverHook.kt │ │ │ │ │ ├── MediaStorerHook.kt │ │ │ │ │ ├── SubdocumentRegistrationHook.kt │ │ │ │ │ ├── TableOfContentsGeneratorHook.kt │ │ │ │ │ ├── location/ │ │ │ │ │ │ ├── LocationAwareLabelStorerHook.kt │ │ │ │ │ │ ├── LocationAwarenessHook.kt │ │ │ │ │ │ └── NumberedEvaluatorHook.kt │ │ │ │ │ ├── presence/ │ │ │ │ │ │ └── ThirdPartyPresenceHook.kt │ │ │ │ │ └── reference/ │ │ │ │ │ ├── BibliographyCitationResolverHook.kt │ │ │ │ │ ├── CrossReferenceResolverHook.kt │ │ │ │ │ ├── FootnoteResolverHook.kt │ │ │ │ │ ├── LinkDefinitionResolverHook.kt │ │ │ │ │ └── ReferenceDefinitionResolverHook.kt │ │ │ │ ├── localization/ │ │ │ │ │ └── ContextLocalization.kt │ │ │ │ ├── subdocument/ │ │ │ │ │ └── SubdocumentsData.kt │ │ │ │ └── toc/ │ │ │ │ └── TableOfContents.kt │ │ │ ├── document/ │ │ │ │ ├── DocumentAuthor.kt │ │ │ │ ├── DocumentInfo.kt │ │ │ │ ├── DocumentTheme.kt │ │ │ │ ├── DocumentType.kt │ │ │ │ ├── layout/ │ │ │ │ │ ├── DocumentLayoutInfo.kt │ │ │ │ │ ├── caption/ │ │ │ │ │ │ ├── CaptionPosition.kt │ │ │ │ │ │ └── CaptionPositionInfo.kt │ │ │ │ │ ├── font/ │ │ │ │ │ │ └── FontInfo.kt │ │ │ │ │ ├── page/ │ │ │ │ │ │ ├── PageFormatInfo.kt │ │ │ │ │ │ ├── PageMarginPosition.kt │ │ │ │ │ │ ├── PageOrientation.kt │ │ │ │ │ │ ├── PageSide.kt │ │ │ │ │ │ └── PageSizeFormat.kt │ │ │ │ │ └── paragraph/ │ │ │ │ │ └── ParagraphStyleInfo.kt │ │ │ │ ├── numbering/ │ │ │ │ │ ├── DocumentNumbering.kt │ │ │ │ │ ├── NumberingCounterSymbol.kt │ │ │ │ │ ├── NumberingFixedSymbol.kt │ │ │ │ │ ├── NumberingFormat.kt │ │ │ │ │ └── NumberingSymbol.kt │ │ │ │ ├── size/ │ │ │ │ │ ├── BoundingBox.kt │ │ │ │ │ ├── Size.kt │ │ │ │ │ └── Sizes.kt │ │ │ │ ├── slides/ │ │ │ │ │ └── Transition.kt │ │ │ │ ├── sub/ │ │ │ │ │ ├── Subdocument.kt │ │ │ │ │ └── SubdocumentOutputNaming.kt │ │ │ │ └── tex/ │ │ │ │ └── TexInfo.kt │ │ │ ├── flavor/ │ │ │ │ ├── LexerFactory.kt │ │ │ │ ├── MarkdownFlavor.kt │ │ │ │ ├── ParserFactory.kt │ │ │ │ ├── RendererFactory.kt │ │ │ │ ├── TreeIteratorFactory.kt │ │ │ │ ├── base/ │ │ │ │ │ ├── BaseMarkdownFlavor.kt │ │ │ │ │ ├── BaseMarkdownLexerFactory.kt │ │ │ │ │ ├── BaseMarkdownParserFactory.kt │ │ │ │ │ ├── BaseMarkdownRendererFactory.kt │ │ │ │ │ └── BaseMarkdownTreeIteratorFactory.kt │ │ │ │ └── quarkdown/ │ │ │ │ ├── QuarkdownFlavor.kt │ │ │ │ ├── QuarkdownLexerFactory.kt │ │ │ │ ├── QuarkdownParserFactory.kt │ │ │ │ ├── QuarkdownRendererFactory.kt │ │ │ │ └── QuarkdownTreeIteratorFactory.kt │ │ │ ├── function/ │ │ │ │ ├── Function.kt │ │ │ │ ├── FunctionParameter.kt │ │ │ │ ├── Naming.kt │ │ │ │ ├── call/ │ │ │ │ │ ├── CallDepth.kt │ │ │ │ │ ├── FunctionCall.kt │ │ │ │ │ ├── FunctionCallArgument.kt │ │ │ │ │ ├── FunctionCallNodeExpander.kt │ │ │ │ │ ├── UncheckedFunctionCall.kt │ │ │ │ │ ├── binding/ │ │ │ │ │ │ ├── AllArgumentsBinder.kt │ │ │ │ │ │ ├── ArgumentsBinder.kt │ │ │ │ │ │ ├── InjectedArgumentsBinder.kt │ │ │ │ │ │ └── RegularArgumentsBinder.kt │ │ │ │ │ └── validate/ │ │ │ │ │ ├── DocumentTypeFunctionCallValidator.kt │ │ │ │ │ └── FunctionCallValidator.kt │ │ │ │ ├── error/ │ │ │ │ │ ├── FunctionCallRuntimeException.kt │ │ │ │ │ ├── FunctionException.kt │ │ │ │ │ ├── InvalidArgumentCountException.kt │ │ │ │ │ ├── InvalidFunctionCallException.kt │ │ │ │ │ ├── MismatchingArgumentTypeException.kt │ │ │ │ │ ├── NoSuchElementException.kt │ │ │ │ │ ├── ParameterAlreadyBoundException.kt │ │ │ │ │ ├── UnnamedArgumentAfterNamedException.kt │ │ │ │ │ ├── UnresolvedParameterException.kt │ │ │ │ │ ├── UnresolvedReferenceException.kt │ │ │ │ │ └── internal/ │ │ │ │ │ └── InvalidExpressionEvalException.kt │ │ │ │ ├── expression/ │ │ │ │ │ ├── ComposedExpression.kt │ │ │ │ │ ├── Expression.kt │ │ │ │ │ ├── SafeExpression.kt │ │ │ │ │ └── visitor/ │ │ │ │ │ ├── AppendExpressionVisitor.kt │ │ │ │ │ ├── EvalExpressionVisitor.kt │ │ │ │ │ └── ExpressionVisitor.kt │ │ │ │ ├── library/ │ │ │ │ │ ├── Library.kt │ │ │ │ │ ├── LibraryExporter.kt │ │ │ │ │ ├── LibraryRegistrant.kt │ │ │ │ │ ├── loader/ │ │ │ │ │ │ ├── FunctionLibraryLoader.kt │ │ │ │ │ │ ├── LibraryLoader.kt │ │ │ │ │ │ ├── MultiFunctionLibraryLoader.kt │ │ │ │ │ │ └── MultiLibraryLoader.kt │ │ │ │ │ └── module/ │ │ │ │ │ └── QuarkdownModule.kt │ │ │ │ ├── reflect/ │ │ │ │ │ ├── DynamicValueConverter.kt │ │ │ │ │ ├── InjectedValue.kt │ │ │ │ │ ├── KFunctionAdapter.kt │ │ │ │ │ ├── ReflectionUtils.kt │ │ │ │ │ └── annotation/ │ │ │ │ │ ├── Injected.kt │ │ │ │ │ ├── Name.kt │ │ │ │ │ ├── NoAutoArgumentUnwrapping.kt │ │ │ │ │ ├── OnlyForDocumentType.kt │ │ │ │ │ └── QuarkdocAnnotations.kt │ │ │ │ └── value/ │ │ │ │ ├── AdaptableValue.kt │ │ │ │ ├── BooleanValue.kt │ │ │ │ ├── Destructurable.kt │ │ │ │ ├── DictionaryValue.kt │ │ │ │ ├── DynamicValue.kt │ │ │ │ ├── EnumValue.kt │ │ │ │ ├── GeneralCollectionValue.kt │ │ │ │ ├── IterableValue.kt │ │ │ │ ├── LambdaValue.kt │ │ │ │ ├── MarkdownContentValue.kt │ │ │ │ ├── NodeValue.kt │ │ │ │ ├── NoneValue.kt │ │ │ │ ├── NumberValue.kt │ │ │ │ ├── ObjectValue.kt │ │ │ │ ├── OrderedCollectionValue.kt │ │ │ │ ├── PairValue.kt │ │ │ │ ├── StringValue.kt │ │ │ │ ├── UnorderedCollectionValue.kt │ │ │ │ ├── Value.kt │ │ │ │ ├── VoidValue.kt │ │ │ │ ├── data/ │ │ │ │ │ ├── EvaluableString.kt │ │ │ │ │ ├── Lambda.kt │ │ │ │ │ └── Range.kt │ │ │ │ ├── factory/ │ │ │ │ │ ├── IllegalRawValueException.kt │ │ │ │ │ └── ValueFactory.kt │ │ │ │ └── output/ │ │ │ │ ├── OutputValueVisitor.kt │ │ │ │ ├── OutputValueVisitorFactory.kt │ │ │ │ └── node/ │ │ │ │ ├── BlockNodeOutputValueVisitor.kt │ │ │ │ ├── InlineNodeOutputValueVisitor.kt │ │ │ │ ├── NodeOutputValueVisitor.kt │ │ │ │ └── NodeOutputValueVisitorFactory.kt │ │ │ ├── graph/ │ │ │ │ ├── Graph.kt │ │ │ │ ├── Graphs.kt │ │ │ │ ├── PersistentDirectedGraph.kt │ │ │ │ └── VisitableOnceGraph.kt │ │ │ ├── lexer/ │ │ │ │ ├── AbstractLexer.kt │ │ │ │ ├── Lexer.kt │ │ │ │ ├── Token.kt │ │ │ │ ├── TokenCoordinates.kt │ │ │ │ ├── TokenData.kt │ │ │ │ ├── patterns/ │ │ │ │ │ ├── BaseMarkdownBlockTokenRegexPatterns.kt │ │ │ │ │ ├── BaseMarkdownInlineTokenRegexPatterns.kt │ │ │ │ │ ├── FunctionCallPatterns.kt │ │ │ │ │ ├── PatternHelpers.kt │ │ │ │ │ ├── QuarkdownBlockTokenRegexPatterns.kt │ │ │ │ │ ├── QuarkdownInlineTokenRegexPatterns.kt │ │ │ │ │ └── TextSymbolReplacement.kt │ │ │ │ ├── regex/ │ │ │ │ │ ├── RegexBuilder.kt │ │ │ │ │ ├── RegexLexer.kt │ │ │ │ │ ├── StandardRegexLexer.kt │ │ │ │ │ └── pattern/ │ │ │ │ │ ├── NamedRegexPattern.kt │ │ │ │ │ ├── RegexPatternGroup.kt │ │ │ │ │ ├── TokenRegexPattern.kt │ │ │ │ │ └── WalkedToken.kt │ │ │ │ └── tokens/ │ │ │ │ ├── BlockTokens.kt │ │ │ │ ├── FunctionCallToken.kt │ │ │ │ └── InlineTokens.kt │ │ │ ├── localization/ │ │ │ │ ├── Locale.kt │ │ │ │ ├── LocaleLoader.kt │ │ │ │ ├── LocalizationExceptions.kt │ │ │ │ ├── LocalizationTable.kt │ │ │ │ └── jvm/ │ │ │ │ ├── JVMLocale.kt │ │ │ │ └── JVMLocaleLoader.kt │ │ │ ├── log/ │ │ │ │ ├── DebugFormatter.kt │ │ │ │ └── Log.kt │ │ │ ├── media/ │ │ │ │ ├── LocalMedia.kt │ │ │ │ ├── Media.kt │ │ │ │ ├── MediaVisitor.kt │ │ │ │ ├── RemoteMedia.kt │ │ │ │ ├── ResolvableMedia.kt │ │ │ │ ├── export/ │ │ │ │ │ └── MediaOutputResourceConverter.kt │ │ │ │ └── storage/ │ │ │ │ ├── MutableMediaStorage.kt │ │ │ │ ├── ReadOnlyMediaStorage.kt │ │ │ │ ├── StoredMedia.kt │ │ │ │ ├── name/ │ │ │ │ │ ├── MediaNameProviderStrategy.kt │ │ │ │ │ └── SanitizedMediaNameProvider.kt │ │ │ │ └── options/ │ │ │ │ ├── MediaStorageOptions.kt │ │ │ │ ├── MediaTypeEnabledChecker.kt │ │ │ │ └── ReadOnlyMediaStorageOptions.kt │ │ │ ├── misc/ │ │ │ │ ├── color/ │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── NamedColor.kt │ │ │ │ │ └── decoder/ │ │ │ │ │ ├── ColorDecoder.kt │ │ │ │ │ ├── ColorDecoderUtils.kt │ │ │ │ │ ├── HexColorDecoder.kt │ │ │ │ │ ├── HsvHslColorDecoder.kt │ │ │ │ │ ├── NamedColorDecoder.kt │ │ │ │ │ └── RgbaColorDecoder.kt │ │ │ │ └── font/ │ │ │ │ ├── FontFamily.kt │ │ │ │ └── resolver/ │ │ │ │ ├── FontFamilyResolver.kt │ │ │ │ └── JVMFontFamilyResolver.kt │ │ │ ├── parser/ │ │ │ │ ├── BlockTokenParser.kt │ │ │ │ ├── FunctionCallRefiner.kt │ │ │ │ ├── InlineTokenParser.kt │ │ │ │ └── walker/ │ │ │ │ ├── GrammarUtils.kt │ │ │ │ ├── WalkerParser.kt │ │ │ │ ├── WalkerParsingResult.kt │ │ │ │ └── funcall/ │ │ │ │ ├── FunctionCallGrammar.kt │ │ │ │ ├── FunctionCallWalkerParser.kt │ │ │ │ └── WalkedFunctionCall.kt │ │ │ ├── pipeline/ │ │ │ │ ├── Pipeline.kt │ │ │ │ ├── PipelineChainFactory.kt │ │ │ │ ├── PipelineHooks.kt │ │ │ │ ├── PipelineOptions.kt │ │ │ │ ├── Pipelines.kt │ │ │ │ ├── error/ │ │ │ │ │ ├── BasePipelineErrorHandler.kt │ │ │ │ │ ├── IOPipelineException.kt │ │ │ │ │ ├── PipelineErrorHandler.kt │ │ │ │ │ ├── PipelineException.kt │ │ │ │ │ ├── StrictPipelineErrorHandler.kt │ │ │ │ │ └── UnattachedPipelineException.kt │ │ │ │ ├── output/ │ │ │ │ │ ├── ArtifactType.kt │ │ │ │ │ ├── BinaryOutputArtifact.kt │ │ │ │ │ ├── LazyOutputArtifact.kt │ │ │ │ │ ├── OutputArtifact.kt │ │ │ │ │ ├── OutputResource.kt │ │ │ │ │ ├── OutputResourceGroup.kt │ │ │ │ │ ├── OutputResourceVisitor.kt │ │ │ │ │ ├── TextOutputArtifact.kt │ │ │ │ │ └── visitor/ │ │ │ │ │ ├── CopyOutputResourceVisitor.kt │ │ │ │ │ └── FileResourceExporter.kt │ │ │ │ ├── stage/ │ │ │ │ │ ├── PeekPipelineStage.kt │ │ │ │ │ ├── PipelineStage.kt │ │ │ │ │ ├── PipelineStageChain.kt │ │ │ │ │ └── SharedPipelineData.kt │ │ │ │ └── stages/ │ │ │ │ ├── AfterAllRenderingPeek.kt │ │ │ │ ├── AttachmentStage.kt │ │ │ │ ├── AttributesUpdateStage.kt │ │ │ │ ├── FunctionCallExpansionStage.kt │ │ │ │ ├── LexingStage.kt │ │ │ │ ├── LibrariesRegistrationStage.kt │ │ │ │ ├── ParsingStage.kt │ │ │ │ ├── PostRenderingStage.kt │ │ │ │ ├── RenderingStage.kt │ │ │ │ ├── ResourceGenerationStage.kt │ │ │ │ └── TreeTraversalStage.kt │ │ │ ├── property/ │ │ │ │ ├── AssociatedProperties.kt │ │ │ │ ├── Property.kt │ │ │ │ └── PropertyContainer.kt │ │ │ ├── rendering/ │ │ │ │ ├── NodeRenderer.kt │ │ │ │ ├── PostRenderer.kt │ │ │ │ ├── RenderingComponents.kt │ │ │ │ ├── UnsupportedRenderException.kt │ │ │ │ ├── representable/ │ │ │ │ │ ├── RenderRepresentable.kt │ │ │ │ │ └── RenderRepresentableVisitor.kt │ │ │ │ └── tag/ │ │ │ │ ├── MultiTagBuilder.kt │ │ │ │ ├── TagBuilder.kt │ │ │ │ └── TagNodeRenderer.kt │ │ │ ├── template/ │ │ │ │ └── TemplateProcessor.kt │ │ │ ├── util/ │ │ │ │ ├── CollectionUtils.kt │ │ │ │ ├── EnumUtils.kt │ │ │ │ ├── EscapeUtils.kt │ │ │ │ ├── IOUtils.kt │ │ │ │ ├── RangeUtils.kt │ │ │ │ ├── ScopedCounter.kt │ │ │ │ ├── StringCase.kt │ │ │ │ ├── StringUtils.kt │ │ │ │ ├── URLUtils.kt │ │ │ │ └── node/ │ │ │ │ ├── NodeUtils.kt │ │ │ │ └── conversion/ │ │ │ │ └── list/ │ │ │ │ ├── MarkdownListConverter.kt │ │ │ │ ├── MarkdownListToCollectionValue.kt │ │ │ │ ├── MarkdownListToDictionaryValue.kt │ │ │ │ ├── MarkdownListToIterable.kt │ │ │ │ └── MarkdownListToList.kt │ │ │ └── visitor/ │ │ │ ├── node/ │ │ │ │ └── NodeVisitor.kt │ │ │ └── token/ │ │ │ ├── BlockTokenVisitor.kt │ │ │ ├── InlineTokenVisitor.kt │ │ │ ├── TokenVisitor.kt │ │ │ └── TokenVisitorAdapter.kt │ │ └── resources/ │ │ └── log4j2.xml │ ├── test/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── core/ │ │ │ ├── AstDslTest.kt │ │ │ ├── BibliographyCitationResolutionTest.kt │ │ │ ├── BlockParserTest.kt │ │ │ ├── ChildContextIsolationTest.kt │ │ │ ├── ChildContextTest.kt │ │ │ ├── CrossReferenceResolutionTest.kt │ │ │ ├── CslBibliographyStyleTest.kt │ │ │ ├── DocumentLayoutInfoTest.kt │ │ │ ├── FileResourceExporterNameSanitizationTest.kt │ │ │ ├── FontTest.kt │ │ │ ├── FootnoteResolutionTest.kt │ │ │ ├── FunctionNodeExpansionTest.kt │ │ │ ├── GraphTest.kt │ │ │ ├── InlineParserTest.kt │ │ │ ├── LambdaTest.kt │ │ │ ├── LexerTest.kt │ │ │ ├── LocaleTest.kt │ │ │ ├── LocalizationTest.kt │ │ │ ├── MediaTest.kt │ │ │ ├── MiscTest.kt │ │ │ ├── NumberingFormatTest.kt │ │ │ ├── NumberingTest.kt │ │ │ ├── PipelineStageTest.kt │ │ │ ├── PropertiesTest.kt │ │ │ ├── StandaloneFunctionTest.kt │ │ │ ├── SubdocumentRegistrationTest.kt │ │ │ ├── TableOfContentsTest.kt │ │ │ ├── TemplateProcessorTest.kt │ │ │ ├── TreeTraversalTest.kt │ │ │ ├── ValueFactoryTest.kt │ │ │ └── util/ │ │ │ └── ScopedCounterTest.kt │ │ └── resources/ │ │ ├── bib/ │ │ │ ├── article.bib │ │ │ ├── bibliography.bib │ │ │ ├── book.bib │ │ │ ├── misc.bib │ │ │ └── online.bib │ │ ├── function/ │ │ │ └── hello.txt │ │ ├── lexing/ │ │ │ ├── blocks.md │ │ │ ├── comment.md │ │ │ ├── emphasis.md │ │ │ ├── entity.md │ │ │ ├── escape.md │ │ │ ├── inline.md │ │ │ ├── inlinefunction.md │ │ │ ├── linebreak.md │ │ │ └── textreplacement.md │ │ ├── parsing/ │ │ │ ├── blockcode.md │ │ │ ├── blockquote.md │ │ │ ├── fencescode.md │ │ │ ├── figure.md │ │ │ ├── footnotedefinition.md │ │ │ ├── functioncall-chain.md │ │ │ ├── functioncall.md │ │ │ ├── heading.md │ │ │ ├── hr.md │ │ │ ├── inline/ │ │ │ │ ├── codespan.md │ │ │ │ ├── emphasis.md │ │ │ │ ├── entity.md │ │ │ │ ├── escape.md │ │ │ │ ├── image.md │ │ │ │ ├── link.md │ │ │ │ ├── mathspan.md │ │ │ │ ├── reffootnote-all-in-one.md │ │ │ │ ├── reffootnote.md │ │ │ │ ├── refimage.md │ │ │ │ ├── reflink.md │ │ │ │ ├── strikethrough.md │ │ │ │ ├── strong.md │ │ │ │ ├── strongemphasis.md │ │ │ │ └── subdocumentlink.md │ │ │ ├── linkdefinition.md │ │ │ ├── math_multiline.md │ │ │ ├── math_oneline.md │ │ │ ├── orderedlist.md │ │ │ ├── pagebreak.md │ │ │ ├── paragraph.md │ │ │ ├── setextheading.md │ │ │ ├── table.md │ │ │ └── unorderedlist.md │ │ ├── subdoc/ │ │ │ ├── subdoc-1.qd │ │ │ └── subdoc-2.qd │ │ └── template/ │ │ └── template.jte │ └── testFixtures/ │ └── kotlin/ │ └── com/ │ └── quarkdown/ │ └── core/ │ └── TestUtils.kt ├── quarkdown-html/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle.kts │ ├── package.json │ ├── playwright.config.ts │ ├── src/ │ │ ├── main/ │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── quarkdown/ │ │ │ │ └── rendering/ │ │ │ │ └── html/ │ │ │ │ ├── HtmlIdentifierProvider.kt │ │ │ │ ├── HtmlTagBuilder.kt │ │ │ │ ├── css/ │ │ │ │ │ ├── CssBuilder.kt │ │ │ │ │ ├── CssPageSelectors.kt │ │ │ │ │ ├── CssRepresentableVisitor.kt │ │ │ │ │ └── StylesheetBuilder.kt │ │ │ │ ├── extension/ │ │ │ │ │ ├── HtmlRendererExtension.kt │ │ │ │ │ └── HtmlRendererFactoryVisitor.kt │ │ │ │ ├── node/ │ │ │ │ │ ├── BaseHtmlNodeRenderer.kt │ │ │ │ │ ├── QuarkdownHtmlNodeRenderer.kt │ │ │ │ │ └── SidebarRenderer.kt │ │ │ │ ├── pdf/ │ │ │ │ │ ├── HtmlPdfExportOptions.kt │ │ │ │ │ ├── HtmlPdfExporter.kt │ │ │ │ │ ├── PdfHtmlPostRendererDecorator.kt │ │ │ │ │ ├── PuppeteerNodeModule.kt │ │ │ │ │ └── PuppeteerPdfGeneratorScript.kt │ │ │ │ ├── post/ │ │ │ │ │ ├── HtmlOnlyPostRenderer.kt │ │ │ │ │ ├── HtmlPostRenderer.kt │ │ │ │ │ ├── HtmlSubdocumentPostRenderer.kt │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── CssFontFacesImporter.kt │ │ │ │ │ │ ├── HtmlDocumentBuilder.kt │ │ │ │ │ │ └── HtmlDocumentStylesheet.kt │ │ │ │ │ └── resources/ │ │ │ │ │ ├── MediaPostRendererResource.kt │ │ │ │ │ ├── PostRendererResource.kt │ │ │ │ │ ├── ProxiedPostRendererResource.kt │ │ │ │ │ ├── ScriptPostRendererResource.kt │ │ │ │ │ ├── SearchIndexPostRendererResource.kt │ │ │ │ │ └── ThemePostRendererResource.kt │ │ │ │ └── search/ │ │ │ │ ├── SearchIndex.kt │ │ │ │ └── SearchIndexGenerator.kt │ │ │ ├── resources/ │ │ │ │ └── pdf/ │ │ │ │ └── pdf.js │ │ │ ├── scss/ │ │ │ │ ├── color/ │ │ │ │ │ ├── beaver.scss │ │ │ │ │ ├── darko.scss │ │ │ │ │ ├── galactic.scss │ │ │ │ │ └── paperwhite.scss │ │ │ │ ├── components/ │ │ │ │ │ ├── _alignment.scss │ │ │ │ │ ├── _bibliography.scss │ │ │ │ │ ├── _block.scss │ │ │ │ │ ├── _blockquote.scss │ │ │ │ │ ├── _box.scss │ │ │ │ │ ├── _clip.scss │ │ │ │ │ ├── _code.scss │ │ │ │ │ ├── _collapsible.scss │ │ │ │ │ ├── _container.scss │ │ │ │ │ ├── _docs.scss │ │ │ │ │ ├── _empty.scss │ │ │ │ │ ├── _figure.scss │ │ │ │ │ ├── _filetree.scss │ │ │ │ │ ├── _float.scss │ │ │ │ │ ├── _focus.scss │ │ │ │ │ ├── _font.scss │ │ │ │ │ ├── _footnote.scss │ │ │ │ │ ├── _heading.scss │ │ │ │ │ ├── _hr.scss │ │ │ │ │ ├── _landscape.scss │ │ │ │ │ ├── _link.scss │ │ │ │ │ ├── _list.scss │ │ │ │ │ ├── _location.scss │ │ │ │ │ ├── _math.scss │ │ │ │ │ ├── _mermaid.scss │ │ │ │ │ ├── _multicolumn.scss │ │ │ │ │ ├── _page-break.scss │ │ │ │ │ ├── _page-margin.scss │ │ │ │ │ ├── _paged.scss │ │ │ │ │ ├── _paragraph.scss │ │ │ │ │ ├── _search.scss │ │ │ │ │ ├── _sidebar.scss │ │ │ │ │ ├── _size.scss │ │ │ │ │ ├── _smooth-scroll.scss │ │ │ │ │ ├── _stack.scss │ │ │ │ │ ├── _table.scss │ │ │ │ │ ├── _toc.scss │ │ │ │ │ ├── _viewport.scss │ │ │ │ │ └── util/ │ │ │ │ │ ├── _heading-selectors.scss │ │ │ │ │ ├── _icon.scss │ │ │ │ │ ├── _location-selectors.scss │ │ │ │ │ ├── _media-queries.scss │ │ │ │ │ └── _misc-selectors.scss │ │ │ │ ├── global.scss │ │ │ │ ├── layout/ │ │ │ │ │ ├── beamer.scss │ │ │ │ │ ├── hyperlegible.scss │ │ │ │ │ ├── latex.scss │ │ │ │ │ ├── minimal.scss │ │ │ │ │ └── util/ │ │ │ │ │ ├── _beamer-toc.scss │ │ │ │ │ ├── _docs-headings-border.scss │ │ │ │ │ ├── _docs-navigation.scss │ │ │ │ │ ├── _gh-tables.scss │ │ │ │ │ ├── _latex-tables.scss │ │ │ │ │ ├── _latex-toc.scss │ │ │ │ │ ├── _minimal-footer.scss │ │ │ │ │ ├── _minimal-tables.scss │ │ │ │ │ ├── _minimal-toc.scss │ │ │ │ │ ├── _progressive-heading-margins.scss │ │ │ │ │ └── _progressive-heading-sizes.scss │ │ │ │ └── locale/ │ │ │ │ └── zh.scss │ │ │ └── typescript/ │ │ │ ├── capabilities.ts │ │ │ ├── chunker/ │ │ │ │ └── page-chunker.ts │ │ │ ├── document/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── document-handler.spec.ts │ │ │ │ ├── document-handler.ts │ │ │ │ ├── global-handlers.ts │ │ │ │ ├── handlers/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── code-highlighter.spec.ts │ │ │ │ │ │ ├── footnotes-document-handler.spec.ts │ │ │ │ │ │ ├── inline-collapsibles.spec.ts │ │ │ │ │ │ ├── math-renderer.spec.ts │ │ │ │ │ │ ├── mermaid-renderer.spec.ts │ │ │ │ │ │ ├── page-margins-docs.spec.ts │ │ │ │ │ │ ├── page-margins-document-handler.spec.ts │ │ │ │ │ │ ├── page-numbers-document-handler.spec.ts │ │ │ │ │ │ ├── persistent-headings.spec.ts │ │ │ │ │ │ └── remaining-height.spec.ts │ │ │ │ │ ├── capabilities/ │ │ │ │ │ │ ├── code-highlighter.ts │ │ │ │ │ │ ├── math-renderer.ts │ │ │ │ │ │ └── mermaid-renderer.ts │ │ │ │ │ ├── docs/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── sibling-pages-buttons.spec.ts │ │ │ │ │ │ ├── page-list-autoscroll.ts │ │ │ │ │ │ ├── search-field-focus.ts │ │ │ │ │ │ ├── search-field.ts │ │ │ │ │ │ ├── sibling-pages-buttons.ts │ │ │ │ │ │ ├── toc-active-tracking.ts │ │ │ │ │ │ └── util/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── page-list-analyzer.spec.ts │ │ │ │ │ │ └── page-list-analyzer.ts │ │ │ │ │ ├── footnotes/ │ │ │ │ │ │ ├── footnotes-docs.ts │ │ │ │ │ │ ├── footnotes-document-handler.ts │ │ │ │ │ │ ├── footnotes-paged.ts │ │ │ │ │ │ ├── footnotes-plain.ts │ │ │ │ │ │ └── footnotes-slides.ts │ │ │ │ │ ├── inline-collapsibles.ts │ │ │ │ │ ├── landscape-size-swapper.ts │ │ │ │ │ ├── page-margins/ │ │ │ │ │ │ ├── page-margins-docs.ts │ │ │ │ │ │ ├── page-margins-document-handler.ts │ │ │ │ │ │ ├── page-margins-paged.ts │ │ │ │ │ │ └── page-margins-slides.ts │ │ │ │ │ ├── page-numbers.ts │ │ │ │ │ ├── paged/ │ │ │ │ │ │ └── split-code-blocks-fix-paged.ts │ │ │ │ │ ├── persistent-headings.ts │ │ │ │ │ ├── remaining-height.ts │ │ │ │ │ ├── show-on-ready.ts │ │ │ │ │ └── sidebar.ts │ │ │ │ ├── paged-like-quarkdown-document.ts │ │ │ │ ├── quarkdown-document.ts │ │ │ │ └── type/ │ │ │ │ ├── docs-document.ts │ │ │ │ ├── paged-document.ts │ │ │ │ ├── plain-document.ts │ │ │ │ └── slides-document.ts │ │ │ ├── footnotes/ │ │ │ │ ├── footnote-dom.ts │ │ │ │ ├── footnote-lookup.ts │ │ │ │ └── footnote-pair.ts │ │ │ ├── index.ts │ │ │ ├── live/ │ │ │ │ └── live-preview.ts │ │ │ ├── navigation/ │ │ │ │ └── active-tracking.ts │ │ │ ├── queue/ │ │ │ │ ├── async-execution-queue.ts │ │ │ │ └── execution-queues.ts │ │ │ ├── search/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── search-highlight.spec.ts │ │ │ │ │ ├── search-result-expander.spec.ts │ │ │ │ │ ├── search-result-renderer.spec.ts │ │ │ │ │ └── search.spec.ts │ │ │ │ ├── search-highlight.ts │ │ │ │ ├── search-result-expander.ts │ │ │ │ ├── search-result-renderer.ts │ │ │ │ └── search.ts │ │ │ ├── types/ │ │ │ │ └── pagedjs.d.ts │ │ │ └── util/ │ │ │ ├── __tests__/ │ │ │ │ ├── escape.spec.ts │ │ │ │ └── page-number.spec.ts │ │ │ ├── browser.ts │ │ │ ├── escape.ts │ │ │ ├── hash.ts │ │ │ ├── id.ts │ │ │ ├── meta.ts │ │ │ ├── numbering.ts │ │ │ └── visibility.ts │ │ └── test/ │ │ ├── e2e/ │ │ │ ├── __util/ │ │ │ │ ├── compile.ts │ │ │ │ ├── css.ts │ │ │ │ ├── free-port.ts │ │ │ │ ├── global-setup.ts │ │ │ │ ├── global-teardown.ts │ │ │ │ ├── live-preview-runner.ts │ │ │ │ ├── paths.ts │ │ │ │ └── runner.ts │ │ │ ├── alerts/ │ │ │ │ ├── box/ │ │ │ │ │ ├── box.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── quote/ │ │ │ │ ├── main.qd │ │ │ │ └── quote.spec.ts │ │ │ ├── alignment/ │ │ │ │ ├── container/ │ │ │ │ │ ├── container.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── doctype/ │ │ │ │ ├── doctype.spec.ts │ │ │ │ └── main.qd │ │ │ ├── captions/ │ │ │ │ ├── customized/ │ │ │ │ │ ├── customized.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── index.ts │ │ │ ├── code/ │ │ │ │ ├── caption/ │ │ │ │ │ ├── caption.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── focus/ │ │ │ │ │ ├── focus.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── no-line-numbers/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── no-line-numbers.spec.ts │ │ │ │ ├── numbered/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── numbered.spec.ts │ │ │ │ └── page-split/ │ │ │ │ ├── main.qd │ │ │ │ └── page-split.spec.ts │ │ │ ├── colors/ │ │ │ │ ├── colors.spec.ts │ │ │ │ └── main.qd │ │ │ ├── cross-reference/ │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── localized/ │ │ │ │ ├── localized.spec.ts │ │ │ │ └── main.qd │ │ │ ├── css/ │ │ │ │ ├── properties/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── properties.spec.ts │ │ │ │ └── raw/ │ │ │ │ ├── main.qd │ │ │ │ └── raw.spec.ts │ │ │ ├── docs/ │ │ │ │ ├── content-width/ │ │ │ │ │ ├── content-width.spec.ts │ │ │ │ │ ├── long-code.qd │ │ │ │ │ ├── main.qd │ │ │ │ │ └── simple.qd │ │ │ │ ├── layout/ │ │ │ │ │ ├── layout.spec.ts │ │ │ │ │ ├── main.qd │ │ │ │ │ └── scroll.spec.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── customization/ │ │ │ │ │ │ ├── _nav.qd │ │ │ │ │ │ ├── _setup.qd │ │ │ │ │ │ ├── main.qd │ │ │ │ │ │ ├── page-1.qd │ │ │ │ │ │ └── structure.spec.ts │ │ │ │ │ └── default/ │ │ │ │ │ ├── _nav.qd │ │ │ │ │ ├── _setup.qd │ │ │ │ │ ├── main.qd │ │ │ │ │ ├── nested/ │ │ │ │ │ │ └── nested-page.qd │ │ │ │ │ ├── page-1.qd │ │ │ │ │ ├── page-2.qd │ │ │ │ │ └── structure.spec.ts │ │ │ │ ├── long-pagelist/ │ │ │ │ │ ├── autoscroll.spec.ts │ │ │ │ │ ├── main.qd │ │ │ │ │ └── page.qd │ │ │ │ └── multi-page/ │ │ │ │ ├── main.qd │ │ │ │ ├── page-1.qd │ │ │ │ ├── page-2.qd │ │ │ │ ├── page-3.qd │ │ │ │ ├── pagelist.spec.ts │ │ │ │ ├── setup.qd │ │ │ │ ├── sibling-pages.spec.ts │ │ │ │ ├── toc-active-tracking.spec.ts │ │ │ │ └── toc.spec.ts │ │ │ ├── doctype/ │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── responsiveness/ │ │ │ │ ├── main.qd │ │ │ │ └── responsiveness.spec.ts │ │ │ ├── filetree/ │ │ │ │ ├── filetree.spec.ts │ │ │ │ └── main.qd │ │ │ ├── font/ │ │ │ │ ├── customization-full/ │ │ │ │ │ ├── customization-full.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── customization-localized/ │ │ │ │ │ ├── customization-localized.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── customization-minimal-no-headings/ │ │ │ │ │ ├── customization-minimal-no-headings.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── customization-minimal-on-headings/ │ │ │ │ │ ├── customization-minimal-on-headings.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── index.ts │ │ │ │ └── localized-default/ │ │ │ │ ├── localized-default.spec.ts │ │ │ │ └── main.qd │ │ │ ├── footnote/ │ │ │ │ ├── multiple/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── multiple.spec.ts │ │ │ │ └── single/ │ │ │ │ ├── main.qd │ │ │ │ └── single.spec.ts │ │ │ ├── hr/ │ │ │ │ ├── hr.spec.ts │ │ │ │ └── main.qd │ │ │ ├── icon/ │ │ │ │ ├── icon.spec.ts │ │ │ │ └── main.qd │ │ │ ├── list/ │ │ │ │ ├── issue_customization-not-affecting-list/ │ │ │ │ │ ├── bulletin.yml │ │ │ │ │ ├── customization-not-affecting-list.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── issue_overjustified-list-in-page-break/ │ │ │ │ ├── bulletin.yml │ │ │ │ ├── main.qd │ │ │ │ └── overjustified-list-in-page-break.spec.ts │ │ │ ├── live-preview/ │ │ │ │ ├── live-preview.spec.ts │ │ │ │ ├── main.qd │ │ │ │ └── sub.qd │ │ │ ├── margin-reset/ │ │ │ │ └── first-in-page/ │ │ │ │ ├── first-in-page.spec.ts │ │ │ │ └── main.qd │ │ │ ├── math/ │ │ │ │ ├── issue_misaligned-in-block/ │ │ │ │ │ ├── bulletin.yml │ │ │ │ │ ├── main.qd │ │ │ │ │ └── misaligned-in-parent.spec.ts │ │ │ │ ├── issue_paged-overflow/ │ │ │ │ │ ├── bulletin.yml │ │ │ │ │ ├── issue_paged-overflow.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── main.qd │ │ │ │ └── math.spec.ts │ │ │ ├── media/ │ │ │ │ ├── main.qd │ │ │ │ └── media.spec.ts │ │ │ ├── mermaid/ │ │ │ │ ├── class/ │ │ │ │ │ ├── class.mmd │ │ │ │ │ ├── class.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── index.ts │ │ │ │ ├── pie/ │ │ │ │ │ ├── main.qd │ │ │ │ │ ├── pie.mmd │ │ │ │ │ └── pie.spec.ts │ │ │ │ └── xy/ │ │ │ │ ├── main.qd │ │ │ │ └── xy.spec.ts │ │ │ ├── multicolumn/ │ │ │ │ ├── all-columns-by-page-fill/ │ │ │ │ │ ├── all-columns-by-page-fill.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── full-span/ │ │ │ │ │ ├── full-span.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── only-first-column/ │ │ │ │ ├── main.qd │ │ │ │ └── only-first-column.spec.ts │ │ │ ├── numbering/ │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── localized/ │ │ │ │ ├── localized.spec.ts │ │ │ │ └── main.qd │ │ │ ├── page-break/ │ │ │ │ ├── main.qd │ │ │ │ └── page-break.spec.ts │ │ │ ├── page-format/ │ │ │ │ ├── alignment-global/ │ │ │ │ │ ├── alignment-global.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── alignment-local/ │ │ │ │ │ ├── alignment-local.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── border/ │ │ │ │ │ ├── border.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── index.ts │ │ │ │ ├── margins/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── margins.spec.ts │ │ │ │ ├── scoped/ │ │ │ │ │ ├── range/ │ │ │ │ │ │ ├── main.qd │ │ │ │ │ │ └── range.spec.ts │ │ │ │ │ ├── range-side/ │ │ │ │ │ │ ├── main.qd │ │ │ │ │ │ └── range-side.spec.ts │ │ │ │ │ └── side/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── side.spec.ts │ │ │ │ └── size/ │ │ │ │ ├── format/ │ │ │ │ │ ├── format.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── format-and-width/ │ │ │ │ │ ├── format-and-width.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── height/ │ │ │ │ │ ├── height.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── width/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── width.spec.ts │ │ │ │ └── width-and-height/ │ │ │ │ ├── main.qd │ │ │ │ └── width-and-height.spec.ts │ │ │ ├── page-margins/ │ │ │ │ ├── all-pages/ │ │ │ │ │ ├── all-pages.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── format-page-numbers/ │ │ │ │ │ ├── format-page-numbers.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── index.ts │ │ │ │ ├── mirror/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── mirror.spec.ts │ │ │ │ ├── page-counter/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── page-counter.spec.ts │ │ │ │ ├── persistent-headings/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── persistent-headings.spec.ts │ │ │ │ └── scoped/ │ │ │ │ ├── main.qd │ │ │ │ └── scoped.spec.ts │ │ │ ├── paragraph/ │ │ │ │ ├── customization/ │ │ │ │ │ ├── customization.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── localized-configuration/ │ │ │ │ │ ├── localized-configuration.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── spacing/ │ │ │ │ │ ├── base/ │ │ │ │ │ │ ├── base.spec.ts │ │ │ │ │ │ └── main.qd │ │ │ │ │ ├── with-container/ │ │ │ │ │ │ ├── main.qd │ │ │ │ │ │ └── with-container.spec.ts │ │ │ │ │ └── with-float/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── with-float.spec.ts │ │ │ │ └── typography/ │ │ │ │ ├── main.qd │ │ │ │ └── typography.spec.ts │ │ │ ├── quarkdown.ts │ │ │ ├── sidebar/ │ │ │ │ ├── empty/ │ │ │ │ │ ├── empty.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── issue_border-in-minimal-theme/ │ │ │ │ │ ├── border-in-minimal-theme.spec.ts │ │ │ │ │ ├── bulletin.yml │ │ │ │ │ └── main.qd │ │ │ │ ├── main.qd │ │ │ │ └── sidebar.spec.ts │ │ │ ├── slides/ │ │ │ │ └── chunking/ │ │ │ │ ├── chunking.spec.ts │ │ │ │ └── main.qd │ │ │ ├── stack/ │ │ │ │ ├── default/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── gap/ │ │ │ │ │ ├── gap.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── grid-row-column-gap/ │ │ │ │ │ ├── grid-row-column-gap.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ └── index.ts │ │ │ ├── text-formatting/ │ │ │ │ ├── case/ │ │ │ │ │ ├── case.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── decoration/ │ │ │ │ │ ├── decoration.spec.ts │ │ │ │ │ └── main.qd │ │ │ │ ├── size/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── size.spec.ts │ │ │ │ ├── style/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── style.spec.ts │ │ │ │ ├── variant/ │ │ │ │ │ ├── main.qd │ │ │ │ │ └── variant.spec.ts │ │ │ │ └── weight/ │ │ │ │ ├── main.qd │ │ │ │ └── weight.spec.ts │ │ │ └── toc/ │ │ │ ├── format-page-numbers/ │ │ │ │ ├── format-page-numbers.spec.ts │ │ │ │ └── main.qd │ │ │ ├── index.ts │ │ │ ├── latex/ │ │ │ │ ├── latex.spec.ts │ │ │ │ └── main.qd │ │ │ ├── minimal/ │ │ │ │ ├── main.qd │ │ │ │ └── minimal.spec.ts │ │ │ └── page-numbers/ │ │ │ ├── main.qd │ │ │ └── page-numbers.spec.ts │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── rendering/ │ │ │ └── html/ │ │ │ ├── HtmlIdentifiersTest.kt │ │ │ ├── HtmlNodeRendererTest.kt │ │ │ ├── HtmlPostRendererTest.kt │ │ │ ├── HtmlSecurityTest.kt │ │ │ ├── HtmlToPdfTest.kt │ │ │ ├── MediaTest.kt │ │ │ ├── SearchIndexGeneratorTest.kt │ │ │ └── SidebarRendererTest.kt │ │ └── resources/ │ │ ├── issues/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── data/ │ │ │ │ └── bibliography/ │ │ │ │ └── bibliography.bib │ │ │ ├── landscape.qd │ │ │ ├── math-page-overflow.qd │ │ │ ├── mermaid-size.qd │ │ │ ├── mirrored-bibliography.qd │ │ │ ├── misaligned-math-fraction.qd │ │ │ ├── misaligned-math-in-box.qd │ │ │ ├── misaligned-math-in-quote.qd │ │ │ ├── multiple-font-configurations.qd │ │ │ └── template/ │ │ │ └── template.qd │ │ ├── media/ │ │ │ └── file.txt │ │ └── rendering/ │ │ ├── block/ │ │ │ ├── blockquote.html │ │ │ ├── code.html │ │ │ ├── footnote.html │ │ │ ├── heading.html │ │ │ ├── math.html │ │ │ ├── orderedlist.html │ │ │ ├── paragraph.html │ │ │ ├── table.html │ │ │ └── unorderedlist.html │ │ ├── inline/ │ │ │ ├── codespan.html │ │ │ ├── emphasis.html │ │ │ ├── image.html │ │ │ ├── link.html │ │ │ ├── math.html │ │ │ ├── reffootnote.html │ │ │ ├── refimage.html │ │ │ ├── reflink.html │ │ │ ├── strikethrough.html │ │ │ ├── strong.html │ │ │ └── strongemphasis.html │ │ └── quarkdown/ │ │ ├── bibliography.html │ │ ├── box.html │ │ ├── clipped.html │ │ ├── collapse.html │ │ ├── container.html │ │ ├── figure.html │ │ ├── filetree.html │ │ ├── fullspan.html │ │ ├── inlinecollapse.html │ │ ├── lastheading.html │ │ ├── navigationcontainer.html │ │ └── texttransform.html │ ├── tsconfig.json │ └── vitest.config.ts ├── quarkdown-interaction/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── interaction/ │ │ ├── Env.kt │ │ ├── executable/ │ │ │ ├── ExecutableWrapper.kt │ │ │ ├── NodeJsWrapper.kt │ │ │ ├── NodeModule.kt │ │ │ ├── NodeNpmHelper.kt │ │ │ ├── NpmWrapper.kt │ │ │ └── WithDefaultPath.kt │ │ └── os/ │ │ └── OsUtils.kt │ └── test/ │ └── kotlin/ │ └── com/ │ └── quarkdown/ │ └── interaction/ │ └── NodeNpmWrapperTest.kt ├── quarkdown-libs/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── resources/ │ ├── docs.qd │ └── paper.qd ├── quarkdown-lsp/ │ ├── LICENSE │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── lsp/ │ │ ├── QuarkdownLanguageServer.kt │ │ ├── QuarkdownLanguageServerLauncher.kt │ │ ├── QuarkdownTextDocumentService.kt │ │ ├── QuarkdownWorkspaceService.kt │ │ ├── TextDocument.kt │ │ ├── cache/ │ │ │ ├── CacheableFunctionCatalogue.kt │ │ │ ├── DocumentCache.kt │ │ │ └── DocumentedFunction.kt │ │ ├── completion/ │ │ │ ├── CompletionItemConverters.kt │ │ │ ├── CompletionSupplier.kt │ │ │ ├── CompletionSuppliersFactory.kt │ │ │ └── function/ │ │ │ ├── AbstractFunctionCompletionSupplier.kt │ │ │ ├── FunctionCallInsertionSnippet.kt │ │ │ ├── name/ │ │ │ │ ├── ChainedFunctionNameCompletionSupplier.kt │ │ │ │ ├── FunctionNameCompletionSupplier.kt │ │ │ │ └── RegularFunctionNameCompletionSupplier.kt │ │ │ └── parameter/ │ │ │ ├── FunctionParameterAllowedValuesCompletionSupplier.kt │ │ │ └── FunctionParameterNameCompletionSupplier.kt │ │ ├── diagnostics/ │ │ │ ├── AbstractFunctionCallDiagnosticsSupplier.kt │ │ │ ├── DiagnosticsSupplier.kt │ │ │ ├── DiagnosticsSuppliersFactory.kt │ │ │ ├── SimpleDiagnostic.kt │ │ │ ├── cause/ │ │ │ │ ├── DiagnosticCause.kt │ │ │ │ ├── DuplicateParameterNameDiagnosticCause.kt │ │ │ │ ├── UnallowedValueDiagnosticCause.kt │ │ │ │ └── UnresolvedParameterNameDiagnosticCause.kt │ │ │ └── function/ │ │ │ ├── FunctionDuplicateParameterNameDiagnosticsSupplier.kt │ │ │ ├── FunctionParameterValueDiagnosticsSupplier.kt │ │ │ └── FunctionUnresolvedParameterNameDiagnosticsSupplier.kt │ │ ├── documentation/ │ │ │ ├── FunctionDocumentation.kt │ │ │ └── HtmlToMarkdown.kt │ │ ├── highlight/ │ │ │ ├── SemanticTokensEncoder.kt │ │ │ ├── SemanticTokensSupplier.kt │ │ │ ├── SemanticTokensSuppliersFactory.kt │ │ │ ├── TokenData.kt │ │ │ ├── TokenType.kt │ │ │ └── function/ │ │ │ ├── FunctionCallTokensSupplier.kt │ │ │ └── ValueQualifier.kt │ │ ├── hover/ │ │ │ ├── HoverSupplier.kt │ │ │ ├── HoverSuppliersFactory.kt │ │ │ └── function/ │ │ │ └── FunctionDocumentationHoverSupplier.kt │ │ ├── ontype/ │ │ │ ├── OnTypeFormattingEditSupplier.kt │ │ │ ├── OnTypeFormattingSuppliersFactory.kt │ │ │ └── TrailingSpacesRemoverOnTypeFormattingEditSupplier.kt │ │ ├── pattern/ │ │ │ └── QuarkdownPatterns.kt │ │ ├── subservices/ │ │ │ ├── CompletionSubservice.kt │ │ │ ├── DiagnosticsSubservice.kt │ │ │ ├── HoverSubservice.kt │ │ │ ├── OnTypeFormattingSubservice.kt │ │ │ ├── SemanticTokensSubservice.kt │ │ │ └── TextDocumentSubservice.kt │ │ ├── tokenizer/ │ │ │ ├── FunctionCall.kt │ │ │ ├── FunctionCallTokenizer.kt │ │ │ └── TokenSearch.kt │ │ └── util/ │ │ ├── FunctionParameterLookupUtils.kt │ │ ├── ParsingUtils.kt │ │ └── PositionUtils.kt │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── lsp/ │ │ ├── DocumentCacheTest.kt │ │ ├── FunctionCallTokenizerTest.kt │ │ ├── FunctionCallTokensSupplierTest.kt │ │ ├── FunctionCompletionSupplierTest.kt │ │ ├── FunctionDocumentationHoverSupplierTest.kt │ │ ├── HtmlToMarkdownTest.kt │ │ ├── PositionTest.kt │ │ ├── TrailingSpacesRemoverFormattingEditSupplierTest.kt │ │ └── diagnostics/ │ │ ├── DiagnosticsTestUtils.kt │ │ ├── FunctionDuplicateParameterNameDiagnosticsSupplierTest.kt │ │ ├── FunctionParameterValueDiagnosticsSupplierTest.kt │ │ └── FunctionUnresolvedParameterNameDiagnosticsSupplierTest.kt │ └── resources/ │ ├── docs/ │ │ ├── com.quarkdown.stdlib.module.Data/ │ │ │ └── csv.html │ │ └── com.quarkdown.stdlib.module.Layout/ │ │ ├── align.html │ │ ├── clip.html │ │ └── column.html │ └── html-to-markdown/ │ ├── align.html │ └── align.md ├── quarkdown-plaintext/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── rendering/ │ │ └── plaintext/ │ │ ├── extension/ │ │ │ └── PlainTextRendererExtension.kt │ │ ├── node/ │ │ │ └── PlainTextNodeRenderer.kt │ │ └── post/ │ │ └── PlainTextPostRenderer.kt │ └── test/ │ └── kotlin/ │ └── com/ │ └── quarkdown/ │ └── rendering/ │ └── plaintext/ │ ├── PlainTextNodeRendererTest.kt │ └── PlainTextPostRendererTest.kt ├── quarkdown-quarkdoc/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── quarkdoc/ │ │ │ └── dokka/ │ │ │ ├── QuarkdocDokkaPlugin.kt │ │ │ ├── kdoc/ │ │ │ │ ├── DeepDocumentationMapper.kt │ │ │ │ ├── DocTagDsl.kt │ │ │ │ ├── DocumentationMapper.kt │ │ │ │ ├── DocumentationMapperDsl.kt │ │ │ │ ├── DocumentationReferencesTransformer.kt │ │ │ │ └── SimpleDocumentationMapper.kt │ │ │ ├── page/ │ │ │ │ ├── DocumentTypeConstraintsPageTransformer.kt │ │ │ │ ├── DocumentablePageTransformer.kt │ │ │ │ ├── LikelyChainedPageTransformer.kt │ │ │ │ ├── NewSectionDocumentablePageTransformer.kt │ │ │ │ └── WikiLinkPageTransformer.kt │ │ │ ├── signature/ │ │ │ │ ├── KotlinSignatureReflectionHelper.kt │ │ │ │ ├── LineBreakingStrategy.kt │ │ │ │ └── QuarkdownSignatureProvider.kt │ │ │ ├── transformers/ │ │ │ │ ├── QuarkdocDocumentableReplacerTransformer.kt │ │ │ │ ├── QuarkdocParameterDocumentationTransformer.kt │ │ │ │ ├── enumeration/ │ │ │ │ │ ├── EnumParameterEntryListerTransformer.kt │ │ │ │ │ ├── EnumStorage.kt │ │ │ │ │ ├── EnumStorer.kt │ │ │ │ │ ├── QuarkdocEnum.kt │ │ │ │ │ └── adapters/ │ │ │ │ │ ├── DokkaEnumAdapter.kt │ │ │ │ │ ├── QuarkdocEnumAdapters.kt │ │ │ │ │ └── ReflectionEnumAdapter.kt │ │ │ │ ├── misc/ │ │ │ │ │ └── DocumentTypeConstraintsTransformer.kt │ │ │ │ ├── module/ │ │ │ │ │ ├── ModuleAsPackageTransformer.kt │ │ │ │ │ ├── ModulesStorer.kt │ │ │ │ │ └── QuarkdownModulesStorage.kt │ │ │ │ ├── name/ │ │ │ │ │ ├── DocumentableNameTransformer.kt │ │ │ │ │ ├── DocumentationNameTransformer.kt │ │ │ │ │ ├── RenamingsStorage.kt │ │ │ │ │ └── RenamingsStorer.kt │ │ │ │ ├── optional/ │ │ │ │ │ └── AdditionalParameterPropertiesTransformer.kt │ │ │ │ ├── suppress/ │ │ │ │ │ └── SuppressInjectedTransformer.kt │ │ │ │ └── type/ │ │ │ │ └── ValueTypeTransformer.kt │ │ │ └── util/ │ │ │ ├── Annotation.kt │ │ │ ├── ContentBuilder.kt │ │ │ ├── DocTagCopy.kt │ │ │ ├── Dri.kt │ │ │ ├── Enum.kt │ │ │ ├── Extra.kt │ │ │ ├── KDoc.kt │ │ │ ├── Package.kt │ │ │ ├── Scraping.kt │ │ │ └── Source.kt │ │ └── resources/ │ │ ├── META-INF/ │ │ │ └── services/ │ │ │ └── org.jetbrains.dokka.plugability.DokkaPlugin │ │ └── styles/ │ │ └── stylesheet.css │ └── test/ │ └── kotlin/ │ └── com/ │ └── quarkdown/ │ └── quarkdoc/ │ └── dokka/ │ ├── AdditionalParameterPropertiesTransformerTest.kt │ ├── DocumentTypeConstraintsTransformerTest.kt │ ├── EnumParameterEntryListerTransformerTest.kt │ ├── LikelyChainedTransformerTest.kt │ ├── ModuleTransformerTest.kt │ ├── NameTransformerTest.kt │ ├── QuarkdocDokkaTest.kt │ ├── QuarkdownSignatureTest.kt │ ├── SuppressInjectedTransformerTest.kt │ ├── ValueTypeTransformerTest.kt │ └── WikiLinkTransformerTest.kt ├── quarkdown-quarkdoc-reader/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── quarkdoc/ │ │ └── reader/ │ │ ├── DocsContentExtractor.kt │ │ ├── DocsFunction.kt │ │ ├── DocsWalker.kt │ │ ├── anchors/ │ │ │ ├── Anchors.kt │ │ │ └── AnchorsHtml.kt │ │ └── dokka/ │ │ ├── DokkaHtmlContentExtractor.kt │ │ └── DokkaHtmlWalker.kt │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── quarkdoc/ │ │ └── reader/ │ │ └── DokkaReaderTest.kt │ └── resources/ │ ├── content/ │ │ ├── capitalize.html │ │ ├── container.html │ │ ├── index.html │ │ ├── isnone.html │ │ ├── lowercase.html │ │ └── uppercase.html │ └── extract/ │ └── lowercase.html ├── quarkdown-server/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── server/ │ │ │ ├── LocalFileWebServer.kt │ │ │ ├── Server.kt │ │ │ ├── ServerEndpoints.kt │ │ │ ├── ServerFreePortScanner.kt │ │ │ ├── browser/ │ │ │ │ ├── BrowserLauncher.kt │ │ │ │ ├── DefaultBrowserLauncher.kt │ │ │ │ ├── EnvBrowserLauncher.kt │ │ │ │ ├── NoneBrowserLauncher.kt │ │ │ │ ├── PathBrowserLauncher.kt │ │ │ │ └── XdgBrowserLauncher.kt │ │ │ ├── endpoints/ │ │ │ │ ├── LivePreviewEndpoint.kt │ │ │ │ └── ReloadEndpoint.kt │ │ │ ├── message/ │ │ │ │ ├── ServerMessage.kt │ │ │ │ └── ServerMessageSession.kt │ │ │ └── stop/ │ │ │ ├── KtorStoppableAdapter.kt │ │ │ └── Stoppable.kt │ │ └── resources/ │ │ ├── live-preview/ │ │ │ └── wrapper.html.jte │ │ └── simplelogger.properties │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── server/ │ │ ├── LivePreviewEndpointTest.kt │ │ ├── LocalFileWebServerTest.kt │ │ └── ServerFreePortScannerTest.kt │ └── resources/ │ └── test.html ├── quarkdown-stdlib/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── quarkdown/ │ │ │ └── stdlib/ │ │ │ ├── Bibliography.kt │ │ │ ├── Collection.kt │ │ │ ├── Data.kt │ │ │ ├── Dictionary.kt │ │ │ ├── Document.kt │ │ │ ├── Ecosystem.kt │ │ │ ├── Emoji.kt │ │ │ ├── Flow.kt │ │ │ ├── Icon.kt │ │ │ ├── Injection.kt │ │ │ ├── Layout.kt │ │ │ ├── Library.kt │ │ │ ├── Localization.kt │ │ │ ├── Logger.kt │ │ │ ├── Logical.kt │ │ │ ├── Math.kt │ │ │ ├── Mermaid.kt │ │ │ ├── MiscElements.kt │ │ │ ├── Optionality.kt │ │ │ ├── Primitives.kt │ │ │ ├── Reference.kt │ │ │ ├── Slides.kt │ │ │ ├── Stdlib.kt │ │ │ ├── String.kt │ │ │ ├── TableComputation.kt │ │ │ ├── Text.kt │ │ │ ├── external/ │ │ │ │ └── QdLibraryExporter.kt │ │ │ └── internal/ │ │ │ ├── Css.kt │ │ │ ├── FileTree.kt │ │ │ ├── Font.kt │ │ │ ├── RootFileSystem.kt │ │ │ ├── Sorting.kt │ │ │ └── Types.kt │ │ └── resources/ │ │ ├── lib/ │ │ │ └── localization.qd │ │ └── text/ │ │ └── lorem-ipsum.txt │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── stdlib/ │ │ ├── DataTest.kt │ │ ├── FileTreeTest.kt │ │ ├── FlowTest.kt │ │ ├── LocalizationTest.kt │ │ ├── TextTest.kt │ │ └── internal/ │ │ ├── CssTest.kt │ │ └── SortingTest.kt │ └── resources/ │ └── data/ │ ├── code.html │ ├── drinks.csv │ ├── listfiles/ │ │ ├── a.txt │ │ ├── b.txt │ │ ├── c.txt │ │ └── d/ │ │ └── d.txt │ ├── people.csv │ └── test.txt ├── quarkdown-test/ │ ├── README.md │ ├── build.gradle.kts │ └── src/ │ └── test/ │ ├── kotlin/ │ │ └── com/ │ │ └── quarkdown/ │ │ └── test/ │ │ ├── BibliographyTest.kt │ │ ├── BoxesTest.kt │ │ ├── CaptionTest.kt │ │ ├── CodeTest.kt │ │ ├── CommentsTest.kt │ │ ├── CrossReferenceTest.kt │ │ ├── DocsLibraryTest.kt │ │ ├── DocumentTest.kt │ │ ├── EcosystemTest.kt │ │ ├── EmojiTest.kt │ │ ├── FileTreeTest.kt │ │ ├── FontTest.kt │ │ ├── FootnoteTest.kt │ │ ├── FormatPageNumberTest.kt │ │ ├── FunctionCallChainingTest.kt │ │ ├── FunctionCallTest.kt │ │ ├── HeadingTest.kt │ │ ├── HtmlOutputResourceTest.kt │ │ ├── IOTest.kt │ │ ├── InjectionTest.kt │ │ ├── IterableTest.kt │ │ ├── LayoutTest.kt │ │ ├── LinkTest.kt │ │ ├── LocalizationTest.kt │ │ ├── MathTest.kt │ │ ├── MediaStorageTest.kt │ │ ├── MermaidTest.kt │ │ ├── NodesTest.kt │ │ ├── NonStrictErrorHandlingTest.kt │ │ ├── NumberingTest.kt │ │ ├── OptionalityTest.kt │ │ ├── PageBreakTest.kt │ │ ├── PageMarginsTest.kt │ │ ├── PaperLibTest.kt │ │ ├── PersistentHeadingTest.kt │ │ ├── ScriptingTest.kt │ │ ├── SecurityTest.kt │ │ ├── SidebarTest.kt │ │ ├── StrictErrorHandlingTest.kt │ │ ├── StringTest.kt │ │ ├── SubdocumentTest.kt │ │ ├── TableComputationTest.kt │ │ ├── TableOfContentsTest.kt │ │ ├── TablesTest.kt │ │ ├── TextTest.kt │ │ ├── UtilitiesTest.kt │ │ └── util/ │ │ ├── Launcher.kt │ │ ├── LibraryUtils.kt │ │ └── OutputResourceUtils.kt │ └── resources/ │ └── data/ │ ├── bib/ │ │ └── bibliography.bib │ ├── code.txt │ ├── css/ │ │ └── style.css │ ├── csv/ │ │ ├── people.csv │ │ ├── sales.csv │ │ └── sums.csv │ ├── include/ │ │ ├── absolute-image.md │ │ ├── basic-source.md │ │ ├── document-info-modification.md │ │ ├── dynamic-value.md │ │ ├── function-definition.md │ │ ├── function-with-content.md │ │ ├── mutable-data.md │ │ ├── read-relative-path-in-scope.md │ │ ├── read-relative-path.md │ │ ├── reference-image.md │ │ ├── relative-image.md │ │ ├── shared-function-usage.md │ │ ├── stdlib-call.md │ │ ├── subdocument-linker.qd │ │ ├── transitive-include.md │ │ └── url-image.md │ ├── libraries/ │ │ ├── content.qd │ │ ├── file-reader.qd │ │ └── hello.qd │ ├── mermaid/ │ │ └── class.mmd │ ├── search-index/ │ │ ├── search-index-no-headings-no-metadata.json │ │ ├── search-index-no-headings-with-metadata.json │ │ ├── search-index-with-headings.json │ │ └── search-index-with-page-margin.json │ └── subdoc/ │ ├── circular-1.qd │ ├── circular-2.qd │ ├── docs/ │ │ ├── _nav.qd │ │ ├── _setup.qd │ │ ├── page-1.qd │ │ ├── page-2.qd │ │ └── page-3.qd │ ├── gateway.qd │ ├── headings-1.qd │ ├── headings-2.qd │ ├── include-lib-1.qd │ ├── include-lib-2.qd │ ├── include-stdlib.qd │ ├── media-storage.qd │ ├── media.qd │ ├── metadata.qd │ ├── nav-includer.qd │ ├── nav.qd │ ├── path-to-root/ │ │ ├── subdoc/ │ │ │ └── subdoc.qd │ │ └── utils/ │ │ └── path-to-root.qd │ ├── recursive.qd │ ├── simple-1.qd │ ├── simple-2.qd │ └── stdlib-call.qd ├── scripts/ │ ├── bootstrap.bat │ └── bootstrap.sh ├── settings.gradle.kts └── version.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ build/ ================================================ FILE: .github/FUNDING.yml ================================================ github: iamgio ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Report a bug or unexpected behavior title: "[Bug] " labels: ["bug"] body: - type: markdown attributes: value: | Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue. - type: checkboxes id: checklist attributes: label: Before submitting options: - label: I am using the latest version of Quarkdown. required: true - label: I am using Java 17 or later (`java -version`). required: true - type: textarea id: description attributes: label: Description description: A clear and concise description of the bug. placeholder: Describe what happened and what you expected to happen instead. validations: required: true - type: textarea id: input attributes: label: Input description: The Quarkdown source that triggers the bug. render: markdown - type: textarea id: output attributes: label: Actual output description: The output you got (rendered result, error message, or stack trace). - type: textarea id: expected attributes: label: Expected output description: What you expected to see instead. - type: input id: os attributes: label: Operating system placeholder: e.g. macOS 15, Windows 11, Ubuntu 24.04 - type: textarea id: additional-context attributes: label: Additional context description: Any other context, screenshots, or information that might help. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Question url: https://github.com/iamgio/quarkdown/discussions/new/choose about: Ask a question or start a discussion. ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.yml ================================================ name: Enhancement suggestion description: Suggest a new feature or improvement title: "[Enhancement] " labels: ["enhancement"] body: - type: markdown attributes: value: | Thanks for suggesting an enhancement! Please describe your idea so we can evaluate it. - type: checkboxes id: checklist attributes: label: Before submitting options: - label: I am using the latest version of Quarkdown. required: true - label: I have checked the [wiki](https://quarkdown.com/wiki) to confirm this feature doesn't already exist. required: true - label: I have searched existing [issues](https://github.com/iamgio/quarkdown/issues) for duplicates. required: true - type: textarea id: description attributes: label: Description description: A clear and detailed description of the enhancement you'd like to see. placeholder: What would you like to be added or changed? validations: required: true - type: textarea id: proposed-syntax attributes: label: Proposed syntax or behavior description: If applicable, show how this feature might look in Quarkdown. render: markdown - type: textarea id: additional-context attributes: label: Additional context description: Any other context, motivation, screenshots, or information that might help. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ - [X] I confirm an issue for this change exists, and it was discussed with maintainers. - [X] I have read the [contributing guidelines](https://github.com/iamgio/quarkdown/blob/main/CONTRIBUTING.md). - [X] I have tested the changes locally. - [ ] (Optional) I have added necessary documentation to [`docs`](https://github.com/iamgio/quarkdown/tree/main/docs) and [CHANGELOG.md](https://github.com/iamgio/quarkdown/blob/main/CHANGELOG.md) ================================================ FILE: .github/actions/bump-scoop/action.yml ================================================ name: "Bump Scoop Manifest" description: "Update Scoop bucket manifest with a new version" inputs: version: description: "Version string to update (e.g. 1.5.0)" required: true type: string committer_token: description: "Personal Access Token to push to the bucket" required: true type: string runs: using: composite steps: - name: Set version variable run: | VERSION="${{ inputs.version }}" VERSION="${VERSION#v}" echo "VERSION=$VERSION" >> $GITHUB_ENV shell: bash - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y jq unzip curl shell: bash - name: Checkout Scoop bucket repo uses: actions/checkout@v3 with: repository: "quarkdown-labs/scoop-quarkdown" token: ${{ inputs.committer_token }} - name: Update manifest version and URL run: | jq --arg ver "$VERSION" \ '.version = $ver | .url = "https://github.com/iamgio/quarkdown/releases/download/v" + $ver + "/quarkdown.zip"' \ bucket/quarkdown.json > bucket/tmp.json mv bucket/tmp.json bucket/quarkdown.json shell: bash - name: Download release zip for checksum run: | curl -L -o quarkdown.zip "https://github.com/iamgio/quarkdown/releases/download/v$VERSION/quarkdown.zip" shell: bash - name: Calculate SHA256 checksum and update manifest run: | SHASUM=$(shasum -a 256 quarkdown.zip | cut -d " " -f 1) jq --arg shasum "$SHASUM" '.hash = $shasum' bucket/quarkdown.json > bucket/tmp.json mv bucket/tmp.json bucket/quarkdown.json shell: bash - name: Commit and push updated manifest run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add bucket/quarkdown.json git diff --cached --quiet || git commit -m "Bump to $VERSION" git push origin main env: GITHUB_TOKEN: ${{ inputs.committer_token }} shell: bash ================================================ FILE: .github/actions/deploy-wiki/action.yml ================================================ name: Deploy Wiki description: Builds and deploys the docs folder to the wiki directory of gh-pages inputs: quarkdown-path: description: "Path to the Quarkdown executable" required: true github-token: description: "GitHub token for deploying to gh-pages" required: true runs: using: composite steps: - name: Build wiki documentation run: | cd docs ${{ inputs.quarkdown-path }} c main.qd --strict --clean --out-name wiki shell: bash - name: Deploy to gh-pages uses: peaceiris/actions-gh-pages@v4 with: personal_token: ${{ inputs.github-token }} external_repository: quarkdown-labs/quarkdown.com publish_dir: docs/output/wiki destination_dir: wiki ================================================ FILE: .github/actions/setup-environment/action.yml ================================================ name: Quarkdown Environment Setup description: Sets up Quarkdown execution environment inputs: full-checkout: description: "If true, fetches full history and tags. Defaults to false." required: false default: 'false' checkout-token: description: "Token used to checkout the repository. Defaults to GITHUB_TOKEN." required: false default: '' runs: using: composite steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: ${{ inputs.full-checkout && '0' || '1' }} fetch-tags: ${{ inputs.full-checkout }} token: ${{ inputs.checkout-token != '' && inputs.checkout-token || github.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - name: Setup Node.js uses: actions/setup-node@v4 - name: Setup Chrome uses: browser-actions/setup-chrome@v1 id: setup-chrome - name: Set Puppeteer's executable path run: | echo "PUPPETEER_EXECUTABLE_PATH=${{ steps.setup-chrome.outputs.chrome-path }}" >> $GITHUB_ENV shell: bash - name: Set npm environment variables run: | mkdir -p $HOME/.npm-global/lib echo "QD_NPM_PREFIX=$HOME/.npm-global/lib" >> $GITHUB_ENV echo "NODE_PATH=$HOME/.npm-global/lib/node_modules" >> $GITHUB_ENV shell: bash - name: Install Puppeteer run: | export PUPPETEER_SKIP_DOWNLOAD=true npm install puppeteer --prefix ${{ env.QD_NPM_PREFIX }} shell: bash ================================================ FILE: .github/actions/update-emoji-list/action.yml ================================================ name: Update Emoji List description: Generates emoji list using Quarkdown and commits it to the gh-pages-src branch inputs: quarkdown-path: description: 'Path to the Quarkdown executable' required: true default: 'quarkdown' runs: using: composite steps: - name: Generate emoji list run: ${{ inputs.quarkdown-path }} c .github/actions/update-emoji-list/generate.qd -o build/qd-ci --nowrap shell: bash - name: Setup gh-pages-src branch for emoji list commit run: | # Save the generated emoji list before switching branches cp build/qd-ci/emoji-list/index.html /tmp/emoji-list.html git stash || true git fetch origin gh-pages-src git checkout gh-pages-src # Copy built emoji list to layout/docs/emoji-list.html, suitable for inclusion in Hugo site, # prepending {{ define "main" }} and appending {{ end }} to the file. { echo '{{ define "main" }}' cat /tmp/emoji-list.html echo '{{ end }}' } > layouts/docs/emoji-list.html shell: bash - name: Commit emoji list to gh-pages-src branch uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: update emoji list" commit_user_name: "github-actions" commit_user_email: "github-actions[bot]@users.noreply.github.com" file_pattern: layouts/docs/emoji-list.html ================================================ FILE: .github/actions/update-emoji-list/generate.qd ================================================ .docname {emoji-list} .var {headers} - Emoji - Code .tablebyrows {.headers} .foreach {.allemojis} emoji code: .pair {.emoji} {.code::codespan} ================================================ FILE: .github/workflows/deploy-wiki.yml ================================================ name: Deploy Wiki on: workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main - name: Build distribution uses: burrunan/gradle-cache-action@v3 with: job-id: main arguments: installDist - name: Deploy wiki uses: iamgio/quarkdown/.github/actions/deploy-wiki@main with: quarkdown-path: ${{ github.workspace }}/build/install/quarkdown/bin/quarkdown github-token: ${{ secrets.COMMITTER_TOKEN }} ================================================ FILE: .github/workflows/generate-pdf/color.txt ================================================ paperwhite darko beaver galactic ================================================ FILE: .github/workflows/generate-pdf/generate-theme-combinations.js ================================================ const fs = require('fs'); const path = require('path'); const PROJECT_DIR = 'mock'; const MAIN_FILE = PROJECT_DIR + '/main.qd'; const WORKFLOW_DIR = '.github/workflows/generate-pdf'; const COLOR_FILE = WORKFLOW_DIR + '/color.txt'; const LAYOUT_FILE = WORKFLOW_DIR + '/layout.txt'; function getLines(file) { return fs.readFileSync(file, 'utf8') .split('\n') .map(l => l.trim()) .filter(l => l.length > 0); } function createNewFileName(color, layout) { return `generated_main_${color}_${layout}.qd`; } function generateThemeVariants() { try { const colors = getLines(COLOR_FILE); const layouts = getLines(LAYOUT_FILE); const mainContent = fs.readFileSync(MAIN_FILE, 'utf8'); // Generate all combinations for (const color of colors) { for (const layout of layouts) { const newFile = `${PROJECT_DIR}/${createNewFileName(color, layout)}`; // Create new content with theme line const themeLine = `.theme color:{${color}} layout:{${layout}}`; const newNameLine = `.docname {${color}_${layout}}`; const newContent = mainContent + '\n\n' + newNameLine + '\n' + themeLine; // Write to file fs.writeFileSync(newFile, newContent); console.log(`Created: ${newFile}`); } } console.log(`Successfully generated ${colors.length * layouts.length} theme variants.`); } catch (error) { console.error('Error:', error.message); process.exit(1); } } generateThemeVariants(); ================================================ FILE: .github/workflows/generate-pdf/layout.txt ================================================ latex minimal beamer hyperlegible ================================================ FILE: .github/workflows/generate-pdf.yml ================================================ name: PDF generation via Puppeteer on: push: tags: [ "v*" ] paths-ignore: - '*.md' - '**/README.md' - 'LICENSE' - '.github/FUNDING.yml' workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main - name: Setup Chrome uses: browser-actions/setup-chrome@v1.7.3 id: setup-chrome - name: Build distribution uses: burrunan/gradle-cache-action@v3 with: job-id: main arguments: distZip - name: Unzip distribution run: unzip -q build/distributions/quarkdown.zip -d build/distributions/ - name: Setup staging directory run: | rm -rf pdf mkdir -p pdf/mock - name: Generate theme combinations run: | node .github/workflows/generate-pdf/generate-theme-combinations.js - name: Compile Mock as PDF run: | for generated in $(ls mock/generated_*.qd); do ./build/distributions/quarkdown/bin/quarkdown c $generated -o pdf/mock --pdf --pdf-no-sandbox done ls -d $PWD/pdf/* - name: Remove directories, keep files run: | rm -rf pdf/mock/*/ ls -d $PWD/pdf/* - name: Upload artifact uses: actions/upload-artifact@v4 with: name: mock-pdf path: pdf/mock - name: Push to generated repo uses: cpina/github-action-push-to-another-repository@v1.7.2 env: API_TOKEN_GITHUB: ${{ secrets.COMMITTER_TOKEN }} with: source-directory: pdf/mock destination-github-username: quarkdown-labs destination-repository-name: generated target-directory: mock commit-message: "Generate Mock PDF files from ${{ github.sha }}" user-name: "github-actions[bot]" user-email: "github-actions[bot]@users.noreply.github.com" ================================================ FILE: .github/workflows/gradle-deploy.yml ================================================ # This workflow will build the project with Gradle and release its build. name: Gradle deploy on: push: branches: [ "main", "workflow-test" ] tags: [ "v*" ] paths-ignore: - '*.md' - '**/README.md' - 'LICENSE' - '.github/ISSUE_TEMPLATE/**' - '.github/FUNDING.yml' jobs: test: uses: iamgio/quarkdown/.github/workflows/gradle-test.yml@main build: runs-on: ubuntu-latest needs: [test] steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main with: full-checkout: true checkout-token: ${{ secrets.COMMITTER_TOKEN }} # Writes the new version (tag name without the leading 'v') to version.txt - name: Bump version if: github.ref_type == 'tag' run: printf "%s" "${GITHUB_REF_NAME#v}" > version.txt - name: Update changelog uses: thomaseizinger/keep-a-changelog-new-release@v2 if: github.ref_type == 'tag' with: tag: ${{ github.ref_name }} - name: Extract release notes id: release-notes if: github.ref_type == 'tag' uses: ffurrer2/extract-release-notes@v3 - name: Commit release updates uses: stefanzweifel/git-auto-commit-action@v7 if: github.ref_type == 'tag' with: commit_message: "chore: release ${{ github.ref_name }}" commit_user_name: "github-actions" commit_user_email: "github-actions[bot]@users.noreply.github.com" file_pattern: version.txt CHANGELOG.md branch: main - name: Build distribution uses: burrunan/gradle-cache-action@v3 with: job-id: main arguments: distZip - name: Unzip distribution (keeping the zip file) run: | mkdir -p build/unzipped unzip -q build/distributions/quarkdown.zip -d build/unzipped echo "$PWD/build/unzipped/quarkdown/bin" >> $GITHUB_PATH - name: Echo output files run: ls -d "$PWD/build/"* - name: Deploy wiki if: github.ref_type == 'tag' uses: iamgio/quarkdown/.github/actions/deploy-wiki@main with: quarkdown-path: ${{ github.workspace }}/build/unzipped/quarkdown/bin/quarkdown github-token: ${{ secrets.COMMITTER_TOKEN }} - name: Distribute artifact uses: actions/upload-artifact@v4 with: name: quarkdown path: build/distributions/quarkdown.zip - name: Move devbuild tag to latest commit run: | git tag -d latest || true git tag -f latest git push origin +latest - name: Create devbuild release uses: softprops/action-gh-release@v2.2.2 with: token: "${{ secrets.GITHUB_TOKEN }}" tag_name: "latest" prerelease: true generate_release_notes: true name: Development build files: build/distributions/quarkdown.zip - name: Create release uses: softprops/action-gh-release@v2 if: github.ref_type == 'tag' with: token: "${{ secrets.GITHUB_TOKEN }}" body: ${{ steps.release-notes.outputs.release_notes }} files: build/distributions/quarkdown.zip - name: Generate docs uses: burrunan/gradle-cache-action@v3 with: job-id: main arguments: quarkdocGenerateAll - name: Deploy docs uses: peaceiris/actions-gh-pages@v4 if: github.ref_type == 'tag' with: personal_token: ${{ secrets.COMMITTER_TOKEN }} external_repository: quarkdown-labs/quarkdown.com publish_dir: ./build/docs destination_dir: docs - name: Deploy devbuild docs uses: peaceiris/actions-gh-pages@v4 with: personal_token: ${{ secrets.COMMITTER_TOKEN }} external_repository: quarkdown-labs/quarkdown.com publish_dir: ./build/docs destination_dir: docs/latest # Bump Homebrew - uses: mislav/bump-homebrew-formula-action@v3 if: github.ref_type == 'tag' with: formula-name: quarkdown homebrew-tap: 'quarkdown-labs/homebrew-quarkdown' download-url: "https://github.com/iamgio/quarkdown/releases/download/${{ github.ref_name }}/quarkdown.zip" env: COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} # Bump Scoop - uses: iamgio/quarkdown/.github/actions/bump-scoop@main if: github.ref_type == 'tag' with: version: ${{ github.ref_name }} committer_token: ${{ secrets.COMMITTER_TOKEN }} dependency-submission: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' # Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies. # See: https://github.com/gradle/actions/blob/main/dependency-submission/README.md - name: Generate and submit dependency graph uses: gradle/actions/dependency-submission@v4 ================================================ FILE: .github/workflows/gradle-test.yml ================================================ # This workflow will build the project with Gradle and release its build. name: Gradle test on: workflow_call: push: branches-ignore: [ "main", "workflow-test" ] pull_request: jobs: unit-test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main if: matrix.os == 'ubuntu-latest' with: full-checkout: ${{ inputs.full-checkout }} - name: Checkout Repository (Windows) if: matrix.os == 'windows-latest' uses: actions/checkout@v4 - name: Set up JDK 17 (Windows) if: matrix.os == 'windows-latest' uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle (Windows) if: matrix.os == 'windows-latest' uses: gradle/actions/setup-gradle@v3 - name: Run tests uses: burrunan/gradle-cache-action@v3 with: job-id: unit-test-${{ matrix.os }} arguments: ${{ matrix.os == 'ubuntu-latest' && 'ktlintCheck test' || 'test' }} e2e-build: runs-on: ubuntu-latest steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main with: full-checkout: ${{ inputs.full-checkout }} - name: Build CLI id: build uses: burrunan/gradle-cache-action@v3 with: job-id: e2e-build arguments: installDist # Building the wiki to ensure it compiles - name: Build wiki run: | cd docs ${{ github.workspace }}/build/install/quarkdown/bin/quarkdown c main.qd --strict --clean - name: Upload CLI artifact uses: actions/upload-artifact@v4 with: name: quarkdown-e2e-cli path: build/install/quarkdown retention-days: 1 e2e-test: runs-on: ubuntu-latest needs: e2e-build strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main with: full-checkout: ${{ inputs.full-checkout }} - name: Download CLI artifact uses: actions/download-artifact@v4 with: name: quarkdown-e2e-cli path: build/install/quarkdown - name: Make CLI executable run: chmod +x build/install/quarkdown/bin/quarkdown - name: Run E2E tests (shard ${{ matrix.shard }}/4) uses: burrunan/gradle-cache-action@v3 env: QUARKDOWN_CLI_PATH: ${{ github.workspace }}/build/install/quarkdown/bin/quarkdown with: job-id: e2e-test arguments: :quarkdown-html:e2eTest -Pshard=${{ matrix.shard }} -PtotalShards=4 ================================================ FILE: .github/workflows/update-emoji-list.yml ================================================ # This workflow updates the emoji list on the gh-pages-src branch. name: Update emoji list on: schedule: # Runs every Sunday at 00:00 UTC - cron: '0 0 * * 0' workflow_dispatch: jobs: update-emoji-list: runs-on: ubuntu-latest steps: - uses: iamgio/quarkdown/.github/actions/setup-environment@main with: full-checkout: true checkout-token: ${{ secrets.COMMITTER_TOKEN }} - name: Build distribution uses: burrunan/gradle-cache-action@v3 with: job-id: main arguments: distZip - name: Unzip distribution run: | mkdir -p build/unzipped unzip -q build/distributions/quarkdown.zip -d build/unzipped echo "$PWD/build/unzipped/quarkdown/bin" >> $GITHUB_PATH - name: Update emoji list uses: iamgio/quarkdown/.github/actions/update-emoji-list@main with: quarkdown-path: ./build/unzipped/quarkdown/bin/quarkdown ================================================ FILE: .gitignore ================================================ ############################## ## Java ############################## .mtj.tmp/ *.class *.jar *.war *.ear *.nar hs_err_pid* replay_pid* ############################## ## Maven ############################## target/ pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next pom.xml.bak release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties .mvn/wrapper/maven-wrapper.jar ############################## ## Gradle ############################## bin/ build/ .gradle .gradletasknamecache gradle-app.setting !gradle-wrapper.jar ############################## ## JTE (Java Template Engine) ############################## jte-classes/ ############################## ## NPM ############################## node_modules/ npm-debug.log ############################## ## IntelliJ ############################## out/ .idea/ .idea_modules/ *.iml *.ipr *.iws ############################## ## Eclipse ############################## .settings/ tmp/ .metadata .classpath .project *.tmp *.bak *.swp *~.nib local.properties .loadpath .factorypath ############################## ## NetBeans ############################## nbproject/private/ nbbuild/ dist/ nbdist/ nbactions.xml nb-configuration.xml ############################## ## Visual Studio Code ############################## .vscode/ .code-workspace ############################## ## OS X ############################## .DS_Store ############################## ## Quarkdown ############################## /*/output/ ############################## ## Miscellaneous ############################## .claude/ *.log **/_test/ ================================================ FILE: .run/CLI_Docs.run.xml ================================================ ================================================ FILE: .run/CLI_Mock.run.xml ================================================ ================================================ FILE: .run/Lint_format.run.xml ================================================ true true false false false false false ================================================ FILE: .run/Test_full_suite.run.xml ================================================ true true false false false false false ================================================ FILE: .run/Test_minimal_suite.run.xml ================================================ true true false false false false false ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [Unreleased] ### Added #### [CSL bibliography styles](https://quarkdown.com/wiki/bibliography) (breaking change) Quarkdown's internal bibliography management is now powered by [CSL](https://citationstyles.org) (Citation Style Language). - A curated selection of citation styles from the [CSL Style Repository](https://github.com/citation-style-language/styles) is now supported. The `style` parameter now accepts a CSL style identifier (e.g. `ieee`, `apa`, `chicago-author-date`, `nature`). The default style is now `ieee`. **Breaking change:** `plain` and `ieeetr` styles do not exist anymore, and have been replaced by `ieee`. - Along with BibTeX (`.bib`) files, the following file formats are now accepted: - CSL JSON (`.json`) - YAML (`.yaml`/`.yml`) - EndNote (`.enl`) - RIS (`.ris`) - Rendered bibliography entries are now localized to the document locale, set via `.doclang`. #### [Multi-key citations](https://quarkdown.com/wiki/bibliography#citations) `.cite` now accepts a comma-separated list of keys (e.g. `.cite {einstein, hawking}`) to produce a single combined citation label, whose format depends on the active citation style (e.g. `[1], [2]` for IEEE, `(Einstein, 1905; Hawking, 1988)` for APA). #### [Scoped page formatting](https://quarkdown.com/wiki/page-format#scoped-formatting) `.pageformat` now supports scoping formats to specific pages in `paged` documents via two combinable parameters: - `side` (`left` or `right`): restricts formatting to recto or verso pages, enabling mirrored margins and other asymmetric layouts. - `pages` (e.g. `2..5`): restricts formatting to an inclusive range of page indices. ```markdown .pageformat size:{A4} .pageformat side:{left} margin:{2cm 3cm 2cm 1cm} .pageformat side:{right} margin:{2cm 1cm 2cm 3cm} ``` ```markdown .pageformat pages:{1..3} borderbottom:{4px} ``` #### [`.heading` primitive function](https://quarkdown.com/wiki/headings) The new `.heading` function creates headings with granular control over their behavior, unlike standard Markdown headings (`#`, `##`, ...). It allows explicit control over numbering (`numbered`), table of contents indexing (`indexed`), page breaks (`breakpage`), depth, and reference ID (`ref`). #### New syntax: [Tight function calls](https://quarkdown.com/wiki/syntax-of-a-function-call#tight-function-calls) Inline function calls can now be wrapped in curly braces to delimit them from surrounding content, without relying on whitespace. ```markdown abc{.uppercase {def}}ghi ``` #### [`.pagebreak` primitive function](https://quarkdown.com/wiki/page-break) The new `.pagebreak` function provides an explicit way to insert a page break as an alternative to the `<<<` syntax. #### [File tree](https://quarkdown.com/wiki/file-tree) The new `.filetree` function renders a visual file tree from a Markdown list. ```markdown .filetree - src - main.ts - ... - README.md ``` Bold entries (`**name**`) are highlighted with a distinct background color, useful for drawing attention to specific items. ```markdown .filetree - src - **main.ts** - utils.ts - README.md ``` #### [Better heading configuration for table of contents and bibliography](https://quarkdown.com/wiki/table-of-contents) Both `.tableofcontents` and `.bibliography` now accept the following optional parameters to control the heading that precedes them: - `breakpage`: controls whether the heading triggers an automatic page break. - `headingdepth`: the depth of the heading (1-6). - `numberheading`: controls whether the heading is numbered in the document hierarchy. - `indexheading`: when enabled, the heading is included in the document's own table of contents. #### [Subscript and superscript text](https://quarkdown.com/wiki/text) The `.text` function now accepts a `script` parameter with `sub` and `sup` values for subscript and superscript text. ### Changed #### Removed `includeunnumbered` parameter from `.tableofcontents` The `includeunnumbered` parameter has been removed, in favor of the more granular heading configuration previously mentioned. Now all indexable headings are included in the ToC by default, regardless of their numbering. #### `.fullspan` now relies on `.container` `.fullspan`, used to create a block spanning over multiple columns in a multi-column layout, is now shorthand for `.container fullspan:{yes}`. ### Fixed #### Stabilized multi-column layout The [multi-column layout](https://quarkdown.com/wiki/multi-column-layout) via `.pageformat columns:{N}` is no longer experimental, and now works reliably across all document types. #### Added call stack limit Infinite recursion in function calls is now detected and reported as a clear error. #### Fixed default browser not opening on Linux (Wayland and XDG environments) On Linux systems where the Java AWT Desktop API does not support the BROWSE action (e.g., Wayland), `--browser default` now falls back to `xdg-open` automatically. Additionally, `--browser xdg` is now a supported named choice for the `--browser` CLI option. Thanks @szy1840! #### Fixed scroll position not fully restored during live preview on long paged documents When editing long paged documents with live preview, the scroll position could sometimes be restored only partially because of long paged.js load times. The swap now reliably waits for the content to be fully loaded. #### Improved lexer performance The lexer has been optimized to reduce regex builds to a minimum, resulting in significantly improved performance for large documents. * * * ### Sponsors Thanks to our sponsors! 🎉 @vitto4 Falconer ## [1.14.1] - 2026-03-06 ### Added #### [Escaped characters in numbering formats](https://quarkdown.com/wiki/numbering) A backslash (`\`) in a numbering format string now escapes the next character, treating it as a fixed symbol. For example, `\1` produces a literal `1` instead of a decimal counter. ### Fixed #### Fixed live preview sometimes timing out on Windows Fixed an IPv6-related issue that caused connections to Quarkdown's server to time out on Windows. _Please also update to the latest version of the VS Code extension to v1.1.2 or later._ #### Fixed block function call incorrectly matching lines with trailing content Fixed an issue that caused a line like `.sum {1} {2} .sum {3} {4}` to be incorrectly lexed as two block function calls rather than a single paragraph with two inline function calls. ### Changed #### Improved lexer performance The lexer no longer restarts its regex search from scratch when a function call advances the scan position, resulting in slightly improved performance, especially for documents with many function calls. ## [1.14.0] - 2026-02-19 This version is the biggest release to date, with a large number of new features and improvements, and a [new official wiki](https://quarkdown.com/wiki), written in Quarkdown, that fully replaces the GitHub wiki for a better experience. > Going forward, next minor releases will be smaller and more frequent. ### Added #### [`docs` document type](https://quarkdown.com/wiki/document-types#docs-docs) `docs` is the fourth document type available in Quarkdown, alongside `plain`, `paged` and `slides`. It is designed for technical documentation, wikis and knowledge bases. It derives from `plain`, and adds a customizable navigation sidebar, a ToC sidebar, a header, accurate client-side search, and next/previous page navigation buttons. You can see it in action in the [new official wiki](https://quarkdown.com/wiki)! To get started with a new `docs` document, you can rely on `quarkdown create` as usual. #### New themes: Galactic (color) and Hyperlegible (layout) Inspired by Astro, this new theme combination is the one used in the new wiki for improved readability and modern look. Galactic+Hyperlegible #### [GitHub-style alerts](https://quarkdown.com/wiki/quote-types) GitHub's alert syntax is now supported, making it easier to migrate from other tools: ```markdown > [!NOTE] > This is a note ``` Note that Quarkdown's original syntax is still supported _and recommended_, especially for English documents: ```markdown > Note: This is a note ``` #### [Subdocument links now allow anchors](https://quarkdown.com/wiki/subdocuments) Links to Quarkdown subdocuments now support anchors, to link to specific sections: ```markdown [Page](page.qd#section) ``` #### [Customizable page numbering format](https://quarkdown.com/wiki/page-counter#formatting-the-page-number) The `.formatpagenumber {format}` function overrides the page numbering format from the current page onward. It accepts the same format specifiers as `.numbering`, and applies to both page counters and table of contents. ```markdown .pagemargin {topcenter} .currentpage # First page .formatpagenumber {i} # Second page # Third page ``` Page number format example Thanks @OverSamu! #### [Horizontal/vertical gap customization of `.grid`](https://quarkdown.com/wiki/stacks#parameters) The `.grid` function now accepts `hgap` and `vgap` parameters to customize the horizontal and vertical gaps between grid items. `gap` still works as a shorthand for both. Thanks @OverSamu! #### [`none` is now converted to `null`](https://quarkdown.com/wiki/none#passing-none-to-functions) When invoking a native function from the stdlib, [`none`](https://quarkdown.com/wiki/none) is now supported by nullable parameters, and converted to `null`. Before: ```markdown .function {rectangle} width height background?: .if {.background::isnone} .container width:{.width} height:{.height} .ifnot {.background::isnone} .container width:{.width} height:{.height} background:{.background} ``` After: ```markdown .function {rectangle} width height background?: .container width:{.width} height:{.height} background:{.background} ``` #### [Icons](https://quarkdown.com/wiki/icons) The new `.icon {name}` function relies on [Bootstrap Icons](https://icons.getbootstrap.com/#icons) to display pixel-perfect icons in your documents. ```markdown Quarkdown is on .icon {github} ``` #### New output target: plain text Quarkdown can now render to plain text (`.txt`) via `--render plaintext`. This has no particular use case. It was needed to implement the docs search feature in the first place. #### Get path to root directory The new `.pathtoroot {granularity?}` function returns the relative path from the current source file to the parent directory of: - the root document, if `granularity` is `project` (default) - the subdocument, if `granularity` is `subdocument` ### Changed #### `.css` doesn't require `!important` anymore The `.css` function now applies `!important` automatically at the end of each rule. #### Revised navigation sidebar The navigation sidebar, visible in `plain` and `paged` documents on web view, is now easier to navigate, with all entries visible at once, and more accessible for screen readers. Sidebar Additionally, its generation is now performed at compile time rather than runtime, providing major performance improvements for large documents. #### Flexible naming strategy for subdocument output files `--no-subdoc-collisions` was removed in favor of `--subdoc-naming `, which is a flexible way to choose how subdocument output files are named: - `file-name` (default): each subdocument output file is named after its source file - `document-name`: each subdocument output file is named after its `.docname` value - `collision-proof`: former `--no-subdoc-collisions` #### Revamped `create` CLI The `quarkdown create` command is now more intuitive, for a smoother onboarding experience. #### Libraries now include content `.include {library}` now also includes top-level Markdown content from the library, just like `.include {file.qd}` does for regular files. #### Page content border adjustments Page content border (`.pageformat bordercolor`) is now supported in `plain` documents, and refined for `slides` documents, especially in PDF output. #### Improved code diff styling Code blocks using the `diff` language now have improved and clearer styling for added and removed lines. ### Fixed #### Major improvements to live preview Live preview has undergone major performance improvements and increased reliability, especially in combination with the new VS Code extension update. Live reloading not being performed when editing subdocuments has also been fixed. #### Fixed subdocument resolution from included files Linking to subdocuments from files included via `.include` from a different directory now correctly resolves the subdocument path. #### Fixed unresolved reference of local variables in body arguments The following snippet used to cause an unresolved reference error for `y`: ```markdown .function {a} x: .x .function {b} y: .a .y .b {hello} ``` #### Fixed paragraph spacing with floating element Fixed an issue that caused no spacing to be present between two paragraphs if a floating element was in-between, via `.float`. #### Fixed ToC with no level 1 headings Table of contents are no longer empty if no level 1 headings are present, or if all are decorative. #### Fixed line spacing in table cells Table cells now correctly apply the same line spacing as paragraphs and lists. * * * ### Sponsors Shout out to our sponsors! 🎉 @vitto4 @serkonda7 Falconer [Unreleased]: https://github.com/iamgio/quarkdown/compare/v1.14.1...HEAD [1.14.1]: https://github.com/iamgio/quarkdown/compare/v1.14.0...v1.14.1 [1.14.0]: https://github.com/iamgio/quarkdown/compare/36ef163d22c13e51edfca12739b99aa6aa1368b4...v1.14.0 ================================================ FILE: CLAUDE.md ================================================ # About Quarkdown This is the Quarkdown project. Quarkdown is a: - Turing-complete Markdown flavor, with a `.qd` standard file extension - Typesetting system, as an alternative to LaTeX, with high-quality typography and layout customization - Compiler, parser and renderer to: - HTML - PDF (via Puppeteer) - Plain text - CLI tool Quarkdown supports different document types, which can be set via the `.doctype {type}` function: - Plain documents (`plain`), suitable for notes, website, etc. Notion-like. - Paged documents (`paged`), suitable for books, articles, reports, etc. LaTeX-like. - Slides (`slides`), suitable for presentations. - Documentation (`docs`), suitable for technical documentation websites and wikis. The Quarkdown flavor extends CommonMark and GFM with various features. The most notable one is *functions*: - Inline function: ```markdown Lorem ipsum .myfunction {arg1} param:{arg2} dolor sit amet. ``` - Block function: ```markdown .myfunction {arg1} param:{arg2} arg3 ``` Quarkdown is dynamically typed, although types do live in the native Kotlin implementation of functions. For a full function call syntax reference, see [here](docs/syntax-of-a-function-call.qd). For any other information, see the [documentation](docs) and the [README](README.md). # Making changes ## Guidelines You are a senior software engineer with high expertise in handling complex codebases, compilers, and typesetting systems. You care about software quality, maintainability, and readability. Avoid repetitive code at all costs and strive for elegant solutions, abstracting common patterns into reusable components. Keep functions and classes small and focused on a single responsibility. It's possible to over-engineer when necessary to achieve high cohesion, low coupling, to anticipate future changes, leveraging design patterns, such as strategy and visitor (frequent in this codebase), and best practices. Write medium-sized documentation comments for all public classes, methods, and properties, and also non-public ones when the logic is not straightforward. Update existing documentation when making changes to the codebase, both in code and [docs](docs), and make sure to keep it consistent with the style used in the project. Aim for a test-driven development (TDD) approach when possible. Tests play an important role. See [Testing](#testing) below. When creating new files, always add them to git via `git add`, and make sure to place them in the correct module and package, following the existing project structure. ## Overview The project is structured as a multi-module Gradle project. - To build, always run `./gradlew installDist` or `distZip` from the root folder. Never run `build`. - To test, run `./gradlew test`, optionally specifying a module, e.g., `:quarkdown-core:test`. - `./gradlew run` is acceptable. ## Compiler The main compiler, located in [quarkdown-core](quarkdown-core), along with rendering extensions, such as [quarkdown-html](quarkdown-html) and [quarkdown-plaintext](quarkdown-plaintext), the language server, located in [quarkdown-lsp](quarkdown-lsp), the CLI, located in [quarkdown-cli](quarkdown-cli), and other modules, is written in Kotlin with the Ktlint code style. Follow the code style used in the project, and make sure to run `./gradlew ktlintFormat` after making changes to ensure the code is properly formatted. ### Pipeline The compiler is structured as a sequential pipeline (`pipeline` package). See `Pipeline-*` files in the [documentation](docs) to understand the different stages (`pipeline/stages` package). ### Context `Context` is the most important interface in the compiler (`context` package). A context contains information about libraries, functions, metadata, settings, and other data needed during compilation. Each function call has a reference to the context it was parsed in. A context can be forked to create a child context with additional or overridden data. There are three forking methods, depending on the implementation, which affect the sandbox level: - `SharedContext`: exchanges information bi-directionally. Changes made in the child context are reflected in the parent context, and vice versa, allowing for full sharing of variables, functions and other declarations. - `ScopeContext`: like `SharedContext`, but the child context does not share new declarations (functions and variables) back to the parent context. This is the behavior used within lambda blocks, such as in `.foreach`. `SubdocumentContext`: no information is shared back to the main file's context, only inherited from it. This also applies to the document info (metadata, title, etc.), This is the behavior used for subdocuments (see [Subdocuments](docs/subdocuments.qd)). ### Nodes Nodes are defined in the `ast/base` or `ast/quarkdown` package, depending on whether they are from CommonMark/GFM or Quarkdown-specific. Defining a new node involves: - Implementing `Node` or `NestableNode`, depending on whether the node can have children or not. Nodes should never be data classes, and `children` must always be the last property. - Implementing `override fun accept(visitor: NodeVisitor): T = visitor.visit(this)` - Adding a `visit` method to `NodeVisitor` and its implementations (`*Renderer`) - Adding lexing/parsing logic (very uncommon in the current state of the project) or, more commonly for non-GFM nodes, defining a native function in the [standard library](#standard-library) that returns the node. See the `Layout` stdlib module for examples. ### Function calls and scripting The function call subsystem spans parsing, resolution, execution, and output mapping. #### Parsing and refinement Source code function calls (e.g. `.foo {x}::bar {y}`) are first extracted by the `FunctionCallWalker` (lexer-level) into `WalkedFunctionCall` structures. These are then refined by `FunctionCallRefiner` into `FunctionCallNode` AST nodes. **Inline vs body arguments:** Inline arguments (inside `{...}`) are eagerly evaluated as expressions via `ValueFactory.safeExpression`, resolving nested function calls at parse time. Body arguments (indented blocks) are stored as raw `DynamicValue` strings for lazy evaluation by the consuming function. This distinction is critical: body arguments intentionally defer evaluation so that the receiving function can choose to use them as raw text, evaluate them as Markdown, or both. **Chaining:** `FunctionCallRefiner` transforms the linked-list chain `.foo {x}::bar {y}` into a nested tree `bar(foo(x), y)`. #### Resolution and execution `FunctionCallNodeExpander` drives function call expansion during the `FunctionCallExpansionStage`: 1. Each `FunctionCallNode` carries the `Context` it was parsed in (`node.context`). 2. Resolution: `node.context.resolveUnchecked(node)` finds the function by name and creates an `UncheckedFunctionCall` with `context = this` (the resolving context). This context is accessible as `call.context` during execution. 3. Execution: the function's `invoke(bindings, call)` runs and returns an `OutputValue`. 4. Output mapping: the result is passed to a `NodeOutputValueVisitor` (block or inline), which converts it to an AST `Node`. For `DynamicValue` results containing raw strings, the visitor calls `parseRaw`, which invokes `ValueFactory.blockMarkdown` or `ValueFactory.inlineMarkdown` to parse the string as Markdown with function expansion. The context used for `parseRaw` is the one held by the `FunctionCallNodeExpander`, which is the context passed to `ValueFactory.markdown` when the current parse cycle was initiated. #### Custom functions and lambdas Custom user-defined functions (`.function` in the `Flow` stdlib module) bridge Quarkdown scripting with the native function system: 1. **Definition:** `Flow.function()` creates a `SimpleFunction` and registers it in a `Library` prefixed with `__func__`. The function's parameters are derived from the Lambda's explicit parameters. 2. **Lambda invocation (`Lambda.invokeDynamic`):** - Forks from `parentContext` (the context where the Lambda was *defined*, not called). - Registers lambda parameter functions via `createLambdaParametersLibrary`: each parameter becomes a zero-arg `SimpleFunction` that returns `DynamicValue(argument.unwrappedValue)`. - Propagates the calling context's libraries (when `callingContext` is provided), so that variable references from the calling scope can be resolved within the lambda body. - Calls the Lambda's `action(arguments, forkedContext)`, which typically runs `ValueFactory.eval(body, forkedContext)`. 3. **`ValueFactory.eval` and recursive resolution:** `eval` parses a raw string as an expression (via `safeExpression`), evaluates it, and returns the result. When the result is a `DynamicValue` wrapping a single-line string different from the input (indicating an intermediate, unresolved reference such as a lambda parameter holding `.y`), `eval` recursively evaluates the result in the same context. Multi-line strings are excluded from recursion as they represent raw Markdown body content intended for lazy evaluation. 4. **Variables:** `Flow.variable()` defines a variable as a function with an optional parameter, acting as both getter and setter. Variable reassignment scans the context hierarchy upward to find the owning context. #### Key files | File | Role | |-------------------------------------------------------|---------------------------------------------------------------------------| | `FunctionCallRefiner` | Refines walked calls into `FunctionCallNode`s, handles chaining | | `FunctionCallNodeExpander` | Expands function call nodes in the AST, maps outputs to nodes | | `FunctionCallExpansionStage` | Pipeline stage that drives expansion | | `Lambda` | Parameterized action block with context forking and argument registration | | `ValueFactory.eval` / `safeExpression` / `expression` | Expression parsing and evaluation | | `NodeOutputValueVisitor` | Converts function output values to AST nodes | | `Flow.kt` (`function`, `variable`) | Custom function and variable definition | ## Standard library The standard library is located in [quarkdown-stdlib](quarkdown-stdlib). It's a *native* library, meaning it's implemented in Kotlin. The stdlib is organized into modules, each one with its own Kotlin source file, with a `QuarkdownModule` declaration, which exposes functions: ```kotlin val Layout: QuarkdownModule = moduleOf( ::container, ::align, ::center, // ... ) ``` The module should then be registered in [Stdlib](quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Stdlib.kt). By default, a function declared as `fun x(y: Type): ReturnType` in Kotlin is exposed to Quarkdown as a function call `.x y:{arg}` that returns a dynamic value. Additionally, `@Name` can be used to rename functions and parameters. For instance, Quarkdown's standard uses lowercase, while Kotlin uses camelCase: ```kotlin @Name("myfunction") fun myFunction( @Name("myparam") myParam: String ): StringValue { // ... } ``` Native functions can also accept and return Quarkdown AST nodes directly, for example: `Paragraph(...).wrappedAsValue()`. `wrappedAsValue()` is available for many value types. Functions must be documented thoroughly with KDoc comments, including examples of usage in Quarkdown syntax. All parameters and return types must be documented. A `Context` parameter can be added to access context information during execution, by declaring it as the first parameter of the function, and marked as `@Injected`. This parameter is not exposed to Quarkdown and must not be documented. ## Quarkdoc Quarkdoc is Quarkdown's documentation generation system, located in [quarkdoc](quarkdoc). It relies on Dokka v2 to generate documentation from KDoc comments in the Kotlin codebase, with custom extensions. Quarkdoc's HTML output is bundled in the build, or can be generated separately via `./gradlew quarkdocGenerateAll` When writing native functions, the following annotations are useful to document them properly: - `@LikelyNamed`: indicates that a parameter is likely to be named rather than positional when called from Quarkdown. For example, `.container width:{100}` instead of `.container {100}`. Using `@Name` implies `@LikelyNamed`. - `@LikelyBody`: indicates that a parameter is likely to be passed as a body block when called from Quarkdown. Body parameters are always the last parameters of a function. ```markdown .container width:{100} This is the body content. ``` - `@LikelyChained`: indicates that a function is likely to be used in a chained manner via the chain syntax (see [Function call syntax](docs/syntax-of-a-function-call.qd#chaining-calls)). For example, in `.myvar::uppercase`, `uppercase` is marked with `@LikelyChained`. - `@OnlyForDocumentType`/`@NotForDocumentType`: indicates that a function is only available for, or not available for, specific document types. An error is raised if the function is called in an incompatible document type. ## HTML front-end The HTML rendering engine is located in [quarkdown-html](quarkdown-html). After the Kotlin extension renders the Quarkdown AST to HTML elements, the front-end TypeScript code takes care of interactivity and dynamic features, while SCSS files handle styling and layout. Additionally, Puppeteer is used to generate PDF output from the HTML rendering, relying on the webserver, located in [quarkdown-server](quarkdown-server). ### Themes Quarkdown allows for a layout theme and a color theme to be selected independently, for more combination possibilities. [scss](quarkdown-html/src/main/scss) exports themes to [theme](quarkdown-html/src/main/resources/render/theme): - `global.css`: global styles - `layout/*.css`: layout styles - `color/*.css`: theme styles ## Server [quarkdown-server](quarkdown-server) is a Ktor-based web server that serves the HTML rendering and allows PDF generation via Puppeteer. The `/preview/` endpoint, used in combination with the CLI's `--preview` and `--watch` options, serves the HTML through a double iframe buffer, allowing for live preview during editing. ## Testing The project has high test coverage, with three types of tests: - Regular unit tests, located in each module's `src/test/kotlin` folder for Kotlin, and `__tests__` folders for TypeScript, which test individual components, classes, and functions in isolation. - Integration unit tests, located in [quarkdown-test](quarkdown-test/src/test/kotlin), which test the compiler as a whole, by compiling Quarkdown source files into different output formats, mainly HTML. - End-to-end tests, located in [e2e](quarkdown-html/src/test/e2e), which test the HTML rendering engine in a real browser environment via Playwright, ensuring HTML output, TypeScript runtime, and CSS styles work correctly together. CSS, in particular, is prone to visual issues that are hard to catch otherwise. When adding or modifying an E2E test, run only the affected test file to speed up the feedback loop: ```bash cd quarkdown-html && npx playwright test path/to/test.spec.ts ``` When making changes to the compiler or other modules, make sure to add or update tests accordingly. ### E2E test structure Each E2E test lives in a directory under `quarkdown-html/src/test/e2e/` containing: - `main.qd`: the Quarkdown source document for the test. - `.spec.ts`: the Playwright spec file. The test framework (`quarkdown.ts`) provides a `suite(testDir)` factory that returns: - `test(name, fn, options?)`: defines a single test case. `options` supports `subpath` for subdocument navigation. - `testMatrix(name, docTypes, fn, options?)`: runs the same test across multiple document types (e.g. `["plain", "paged", "slides"]`), creating separate test cases for each. The runner prepends `.doctype {type}` to the source automatically. This is the only way to specify a document type; `test()` does not support `docType`. - `expect`: Playwright's `expect` for assertions. The runner (`__util/runner.ts`) compiles the source via the CLI, navigates to the Quarkdown server, and waits for `window.isReady()`. Each test run gets a unique ID for parallel isolation. Utility helpers in `__util/css.ts` provide computed style access. ## Documentation Documentation files are located in the [docs](docs) folder, and are written in Quarkdown itself. When making changes to the compiler or other modules, features or changes, make sure to also update the documentation accordingly, along with [CHANGELOG](CHANGELOG.md). The changelog follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, uses [Semantic Versioning](https://semver.org/), uses extensive description for each major change, with links to the corresponding documentation at `https://quarkdown.com/wiki/Page`. When writing documentation, you're an expert technical writer who follows these guidelines: - Use American English spelling. - Use active voice. - Be concise and clear, but not at the cost of clarity. Avoid unnecessary jargon but also ambiguity. - Use consistent terminology. For example, always use "function call" instead of sometimes "function invocation". - Use a professional and friendly tone, and be as human as possible. Avoid overly technical or robotic language. Avoid en-dashes, em-dashes, and emojis. To demo a source+output example, use functions defined in [`_Setup.qd`](docs/_setup.qd): - `.examplemirror` for showing both source code and rendered output side-by-side. This is great for Quarkdown snippets that don't affect the overall document structure or style. - `.example` for showing the source code and a manual output, such as an image. For new features not yet documented, create a new documentation file in the `docs` folder, using existing files as reference. ### Compiling the documentation To compile it, run the following command from the `docs` folder via `gradlew run`: ```bash c main.qd --clean ``` This will generate the documentation website in `docs/output/Quarkdown-Wiki`. ================================================ FILE: CONTRIBUTING.md ================================================ [Issues]: https://github.com/iamgio/quarkdown/issues [Issue]: https://github.com/iamgio/quarkdown/issues [Discussions]: https://github.com/iamgio/quarkdown/discussions [Discussion]: https://github.com/iamgio/quarkdown/discussions [wiki]: https://quarkdown.com/wiki [documentation]: https://quarkdown.com/docs [standard library]: https://github.com/iamgio/quarkdown/tree/main/quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib # Contributing to Quarkdown Thanks for interest in contributing to Quarkdown, the Markdown-based typesetting system, and its ecosystem! All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution, as it will make it easier for maintainers to handle it. > If you like the project, but don't have time to contribute, that's totally fine! > You can still support us and show your appreciation by doing any of the following: > - Star :star2: the project. > - Post the project on social media. > - Mention the project to others. ## Table of Contents - [Questions](#questions) - [Contributing via issues](#contributing-via-issues) - [Reporting Bugs](#reporting-bugs) - [Suggesting Enhancements](#suggesting-enhancements) - [Contributing via PR](#contributing-via-pr) - [Your first contribution](#your-first-contribution) - [Understanding the architecture](#understanding-the-architecture) - [Styleguides](#styleguides) ## Questions Before you ask a question, it is best to search for existing [Issues] or [Discussions] that might help you. If you then still feel the need to ask a question and need clarification, we recommend the following: - Open a [Discussion](https://github.com/iamgio/quarkdown/discussions/new/choose) or [Issue](https://github.com/iamgio/quarkdown/issues/new), depending on what you feel is more appropriate for your question. - Provide as much context as you can about what you're running into. - Provide project version, along with JVM version and OS if relevant. We will then take care of the issue as soon as possible. ## Contributing via issues > ### Legal Notice > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. ### Reporting Bugs #### Before submitting a bug report A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. - Make sure that you are using the latest version. - Determine if your bug is really a bug and not an error on your side. - Check if there is not already an issue for your bug in [Issues]. #### Submitting Open an [Issue] with a clear and descriptive title. The body should contain the following information: - Your input - The output or stack trace - JVM version - Operating system - Can you reliably reproduce the issue? And can you also reproduce it with older versions? ### Suggesting Enhancements #### Before submitting an enhancement - Make sure that you are using the latest version. - Check the [wiki] and [documentation] carefully to check if the functionality is already present. - Check [Issues] to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. #### Submitting Open an [Issue] with a clear and descriptive title. The body should contain the following information: - Provide a step-by-step description of the suggested enhancement in as many details as possible. - Describe the current behavior and explain which behavior you expected to see instead. At this point you can also tell which alternatives do not work for you. - Explain why this enhancement would be useful. ## Contributing via PR ### Your first contribution > [!IMPORTANT] > Please **open a PR only after opening an [Issue]** for the change you want to make, so that maintainers can give you feedback on whether your contribution is likely to be accepted and how it should be implemented. The following list shows contributions that are highly welcome, in order of importance: 1. [Issues] labeled with `good first issue` or `help wanted`. These issues are usually easier to solve and are a good starting point for new contributors. 2. Improve the **documentation** of the [standard library], which will be shown in the auto-generated [documentation]. To have a preview of the generated documentation, you can run `gradlew quarkdocGenerate` 3. Improve performance of the pipeline. 4. Add new functions to the [standard library]. It's suggested to open an [enhancement suggestion](#suggesting-enhancements) first. 5. Add new [themes](https://github.com/iamgio/quarkdown/tree/main/quarkdown-html/src/main/scss). Please ensure your theme looks correctly on all document types (`plain`, `paged`, `slides`, `docs`) on the [Mock document](https://github.com/iamgio/quarkdown/tree/main/mock) and [Quarkdown's wiki](https://github.com/iamgio/quarkdown/tree/main/docs). ### Understanding the architecture The architecture behind Quarkdown's core is explained in the wiki's [*Pipeline*](https://quarkdown.com/wiki/pipeline). ## Tooling > If you're using IntelliJ IDEA, you can import run configurations from the `.run` directory. ### Building The project uses Gradle as its build system. To build the project, always run: ```bash ./gradlew installDist ``` > [!WARNING] > Avoid `./gradlew build`, always use `installDist` or `distZip` instead. ### Testing To run the full test suite: ```bash ./gradlew test ``` You can also run tests for a specific module: ```bash ./gradlew :quarkdown-core:test ./gradlew :quarkdown-html:test ``` End-to-end tests are heavy and aren't included in `:test`. They can be run with: ```bash ./gradlew :quarkdown-html:e2eTest ``` Note that all tests are automatically run on every PR. ### Running the CLI You can run the Quarkdown CLI directly via Gradle, without needing to build the project first: ```bash ./gradlew run --args="c [options] --libs quarkdown-libs/src/main/resources" ``` ### Documentation - To compile the [wiki](docs), run either of the following commands from the `docs` directory: - ```bash quarkdown c main.qd --clean ``` - ```bash ./gradlew run --args="c main.qd --clean --libs ../quarkdown-libs/src/main/resources" ``` - To generate Quarkdoc documentation only for the standard library: ```bash ./gradlew quarkdocGenerate ``` - To generate Quarkdoc documentation for the whole project: ```bash ./gradlew quarkdocGenerateAll ``` ## Styleguides #### Kotlin code style Quarkdown uses [ktlint](https://github.com/pinterest/ktlint) to ensure a consistent codestyle is kept across the whole project. Upon opening a PR, `./gradlew ktlintCheck` is automatically run, and the checks must pass before the PR can be merged. You can also run `./gradlew ktlintFormat` to automatically fix any formatting issues in your code. #### Commit messages Please ensure your commit messages use the [imperative tense](https://cbea.ms/git-commit/#imperative) and following the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) specification, so that they are clear and consistent across the project. ## Attribution This file was inspired by [contributing.md](https://contributing.md/). ================================================ FILE: Dockerfile ================================================ # Build stage via Gradle FROM gradle:8.14.3-jdk17 AS builder COPY . /app WORKDIR /app # Build the distribution zip RUN gradle --no-daemon distZip # For testing purposes, replace the Gradle build with the following to reduce delays. # RUN mkdir -p build/distributions # RUN curl -L -o build/distributions/quarkdown.zip https://github.com/iamgio/quarkdown/releases/download/latest/quarkdown.zip WORKDIR build/distributions RUN unzip quarkdown.zip && rm quarkdown.zip # Run stage FROM ghcr.io/puppeteer/puppeteer:24.15.0 AS runner # Install JDK USER root RUN apt-get update && apt-get install -y openjdk-17-jdk \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* USER pptruser WORKDIR /app COPY --from=builder /app/build/distributions/quarkdown quarkdown ENV PATH="/app/quarkdown/bin:${PATH}" ENTRYPOINT ["quarkdown"] LABEL org.opencontainers.image.vendor="Quarkdown" LABEL org.opencontainers.image.title="Quarkdown Docker image" LABEL org.opencontainers.image.description="Versatile Markdown-based typsetting system." LABEL org.opencontainers.image.authors="Giorgio Garofalo (iamgio) and contributors " LABEL org.opencontainers.image.url="https://quarkdown.com" LABEL org.opencontainers.image.source="https://github.com/iamgio/quarkdown" LABEL org.opencontainers.image.documentation="https://quarkdown.com/docs/" LABEL org.opencontainers.image.licenses="GPL-3.0" ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Copyright (C) 2025 Giorgio Garofalo (iamgio) Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

Quarkdown banner
Wiki Docs Release Visual Studio Code Extension Version FMT: Ktlint CodeFactor

iamgio%2Fquarkdown | Trendshift

Releases
Latest   |   Stable 


# Table of contents 1. [About](#about) 2. [Demo](#as-simple-as-you-expect) 3. [Targets](#targets) 4. [Comparison](#comparison) 5. [Getting started](#getting-started) 1. [Installation](#installation) 2. [Quickstart](#quickstart-) 3. [Creating a project](#creating-a-project) 4. [Compiling](#compiling) 6. [Mock document](#mock-document) 7. [Contributing](#contributing) 8. [Sponsors](#sponsors) 9. [Concept](#concept) 10. [License](#license)   # About Quarkdown is a modern Markdown-based typesetting system designed for **versatility**. It allows a single project to compile seamlessly into a print-ready book, academic paper, knowledge base, or interactive presentation. All through an incredibly powerful Turing-complete extension of Markdown, ensuring your ideas flow automatically into paper.  

Paper demo

Original credits: Attention Is All You Need


Born as an extension of CommonMark and GFM, the Quarkdown Flavor brings **functions** to Markdown, along with many other syntax extensions.
> This is a function call: > ``` > .somefunction {arg1} {arg2} > Body argument > ```
**Possibilities are unlimited** thanks to an ever-expanding [standard library](quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib), which offers layout builders, I/O, math, conditional statements and loops. **Not enough?** You can still define your own functions and variables — all within Markdown. You can even create awesome libraries for everyone to use.
> ``` > .function {greet} > to from: > **Hello, .to** from .from! > > .greet {world} from:{iamgio} > ``` > Result: **Hello, world** from iamgio!
This out-of-the-box scripting support opens doors to complex and dynamic content that would be otherwise impossible to achieve with vanilla Markdown. Combined with live preview, :zap: fast compilation speed and a powerful [VS Code extension](https://marketplace.visualstudio.com/items?itemName=quarkdown.quarkdown-vscode), Quarkdown simply gets the work done, whether it's an academic paper, book, knowledge base or interactive presentation.  

Live preview

  ---

Looking for something?

Check out the wiki to get started and learn more about the language and its features!

---   ## As simple as you expect...

Paper code demo

Inspired by: X-ray flashes from a nearby supermassive black hole accelerate mysteriously

 

...as complex as you need.

Chart code demo

# Targets - **HTML** - [X] **Plain** Continuous flow like Notion/Obsidian, perfect for static websites and knowledge management - check out the author's [personal website](https://iamgio.eu/). - [X] **Paged** via [paged.js](https://pagedjs.org) Perfect for papers, articles and books - check out the [demo document](https://github.com/quarkdown-labs/generated/blob/main/mock/paperwhite_latex.pdf). - [X] **Slides** via [reveal.js](https://revealjs.com) Perfect for interactive presentations. - [X] **Docs** Perfect for wikis, technical documentation and large knowledge bases - check out [Quarkdown's wiki](https://quarkdown.com/wiki). - **PDF** - [X] All document types and features supported by HTML are also supported when exporting to PDF. - **Plain text** The desired document type can be set by calling the [`.doctype` function](https://quarkdown.com/wiki/document-types) within the source itself: - `.doctype {plain}` (default) - `.doctype {paged}` - `.doctype {slides}` - `.doctype {docs}` # Comparison | | Quarkdown | LaTeX | Typst | AsciiDoc | MDX | |-----------------------|:------------------:|:------------------:|:------------------:|:------------------:|:------------------:| | Concise and readable | :white_check_mark: | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Full document control[^control] | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Scripting | :white_check_mark: | Partial | :white_check_mark: | :x: | :white_check_mark: | | Book/article export | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | Third-party | | Presentation export | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | Third-party | | Static site export | :white_check_mark: | :x: | Experimental | :white_check_mark: | :white_check_mark: | | Docs/wiki export | :white_check_mark: | :x: | :x: | :white_check_mark: | :white_check_mark: | | Learning curve | :green_circle: | :red_circle: | :orange_circle: | :green_circle: | :green_circle: | | Targets | HTML, PDF, TXT | PDF, PostScript | HTML, PDF | HTML, PDF, ePub | HTML | [^control]: The ability to customize the properties of the document and of its output artifact through the language itself.
LaTeX Quarkdown
```latex \tableofcontents \section{Section} \subsection{Subsection} \begin{enumerate} \item \textbf{First} item \item \textbf{Second} item \end{itemize} \begin{center} This text is \textit{centered}. \end{center} \begin{figure}[!h] \centering \begin{subfigure}[b] \includegraphics[width=0.3\linewidth]{img1.png} \end{subfigure} \begin{subfigure}[b] \includegraphics[width=0.3\linewidth]{img2.png} \end{subfigure} \begin{subfigure}[b] \includegraphics[width=0.3\linewidth]{img3.png} \end{subfigure} \end{figure} ``` ```markdown .tableofcontents # Section ## Subsection 1. **First** item 2. **Second** item .center This text is _centered_. .row alignment:{spacebetween} ![Image 1](img1.png) ![Image 2](img2.png) ![Image 3](img3.png) ```
  # Getting started ## Installation ### Install script (Linux/macOS) ```shell curl -fsSL https://raw.githubusercontent.com/quarkdown-labs/get-quarkdown/refs/heads/main/install.sh | sudo env "PATH=$PATH" bash ``` Root privileges let the script install Quarkdown into `/opt/quarkdown` and its wrapper script into `/usr/local/bin/quarkdown`. If missing, Java 17, Node.js and npm will be installed automatically using the system's package manager. For more installation options, check out [get-quarkdown](https://github.com/quarkdown-labs/get-quarkdown). ### Homebrew (Linux/macOS) ```shell brew install quarkdown-labs/quarkdown/quarkdown ``` ### Install script (Windows) ```powershell irm https://raw.githubusercontent.com/quarkdown-labs/get-quarkdown/refs/heads/main/install.ps1 | iex ``` ### Scoop (Windows) ```shell scoop bucket add java scoop bucket add quarkdown https://github.com/quarkdown-labs/scoop-quarkdown scoop install quarkdown ``` ### GitHub Actions See [setup-quarkdown](https://github.com/quarkdown-labs/setup-quarkdown) to easily integrate Quarkdown into your GitHub Actions workflows. ### Manual installation
Instructions for manual installation Download `quarkdown.zip` from the [latest stable release](https://github.com/iamgio/quarkdown/releases/latest) and unzip it, or build it with `gradlew installDist`. Optionally, adding `/bin` to your `PATH` allows you easier access Quarkdown. Requirements: - Java 17 or higher - (Only for PDF export) Node.js, npm, Puppeteer. See [*PDF export*](https://quarkdown.com/wiki/pdf-export) for details.
  ## Quickstart 🆕 New user? You'll find **everything you need** in the **[Quickstart guide](https://quarkdown.com/wiki/quickstart)** to bring life to your first document!   ## Creating a project **`quarkdown create [directory]`** will launch the prompt-based project wizard, making it quicker than ever to set up a new Quarkdown project, with all [metadata](https://quarkdown.com/wiki/document-metadata) and initial content already present. For more information about the project creator, check out its [wiki page](https://quarkdown.com/wiki/cli-project-creator). Alternatively, you may manually create a `.qd` source file and start from there.   ## Compiling Running **`quarkdown c file.qd`** will compile the given file and save the output to file. > If the project is composed by multiple source files, the target file must be the root one, i.e. the one that includes the other files. > > - [How to include other files?](https://quarkdown.com/wiki/including-other-quarkdown-files) If you would like to familiarize yourself with Quarkdown instead, `quarkdown repl` lets you play with an interactive REPL mode. #### Options - **`-p`** or **`--preview`**: enables automatic content reloading after compiling. If a [webserver](https://quarkdown.com/wiki/cli-webserver) is not running yet, it is started and the document is opened in the default browser. This is required in order to render paged documents in the browser. - **`-w`** or **`--watch`**: recompiles the source everytime a file from the source directory is changed. > [!TIP] > Combine `-p -w` to achieve ***live preview***! - **`--pdf`**: produces a PDF file. Learn more in the wiki's [*PDF export*](https://quarkdown.com/wiki/pdf-export) page. - `-o ` or `--out `: sets the directory of the output files. Defaults to `./output`. - `--out-name `: sets the name of the output resource to be saved inside the output directory. Defaults to the name of the document, set via [`.docname`](https://quarkdown.com/wiki/document-metadata). *Note:* special characters will be replaced with dashes in the actual file name. - `-l ` or `--libs `: sets the directory where external libraries can be loaded from. Defaults to `/lib/qd`. [(?)](https://quarkdown.com/wiki/importing-external-libraries) - `-r ` or `--render `: sets the target renderer. Defaults to `html`. Accepted values: - `html` - `html-pdf` (equivalent to `-r html --pdf`) - `text` (plain text) - `-b ` or `--browser `: sets the browser to launch the preview with. Defaults to `default`. Accepted values: - `default` - `none` - `xdg` (uses `xdg-open`). `default` falls back to this. - `chrome` - `chromium` - `firefox` - `edge` (Windows only) - Any other name, backed by the `BROWSER_` environment variable - A full path to a browser executable - `--server-port `: optional customization of the local webserver's port. Defaults to `8089`. - `--pipe`: outputs the generated content to stdout instead of saving it to file and suppresses other logs, useful for piping to other commands. - `--clean`: deletes the content of the output directory before producing new files. Destructive operation. - `--strict`: forces the program to exit if an error occurs. When not in strict mode, errors are shown as boxes in the document. - `--nowrap`: prevents the rendered output from being wrapped in a full document structure. If enabled in HTML rendering, only the inner content of the `` tag is produced. - `--pretty`: produces pretty output code. This is useful for debugging or to read the output code more easily, but it should be disabled in production as the results might be visually affected. - `--no-media-storage`: turns the media storage system off. [(?)](https://quarkdown.com/wiki/media-storage) - `--subdoc-naming `: sets the subdocument output naming strategy [(?)](https://github.com/iamgio/quarkdown/wiki/subdocuments). Defaults to `file-name`. Accepted values: - `file-name`: uses the subdocument's file name (human-readable, but prone to collisions) - `collision-proof`: appends a hash to `file-name` to minimize name collisions - `document-name`: uses the document name set via `.docname`, falling back to `file-name` if unset - `-Dloglevel=` (JVM property): sets the log level. If set to `warning` or higher, the output content is not printed out.   ---   ## Mock document  

Mock document demo

***Mock***, written in Quarkdown, is a comprehensive collection of visual elements offered by the language, making it ideal for exploring and understanding its key features — all while playing and experimenting hands-on with a concrete outcome in the form of pages or slides. - The document's source files are available in the [`mock`](mock) directory, and can be compiled via `quarkdown c mock/main.qd -p`. - The PDF artifacts generated for all possible theme combinations are available and can be viewed in the [`generated`](https://github.com/quarkdown-labs/generated) repo. ## Contributing Contributions are welcome! Please check [CONTRIBUTING.md](CONTRIBUTING.md) to know how contribute via issues or pull requests. ## Sponsors A special thanks to all the sponsors who [supported this project](https://github.com/sponsors/iamgio)!

Falconer

RayOffiah

vitto4

LunaBluee  dcopia Pallandos imogenxingren serkonda7

## Concept The logo resembles the original [Markdown icon](https://github.com/dcurtis/markdown-mark), with focus on Quarkdown's completeness, richness of features and customization options, emphasized by the revolving arrow all around the sphere.

Quarkdown icon

What could be mistaken for a planet is actually a **quark** or, more specifically, a **down quark**, an elementary particle that is a major constituent of matter: they give life to every complex structure we know of, while also being one of the lightest objects in existence. This is, indeed, the concept **Quarkdown** is built upon. ## License By default, Quarkdown and its modules are licensed under [GNU GPLv3](./LICENSE), except for modules that include their own `LICENSE` file: the CLI (`quarkdown-cli`) and Language Server (`quarkdown-lsp`) modules and binaries are licensed under GNU AGPLv3. ## Footnotes ================================================ FILE: build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.time.Year plugins { kotlin("jvm") version "2.3.10" id("org.jetbrains.dokka") version "2.0.0" id("org.jlleitschuh.gradle.ktlint") version "14.0.1" id("com.github.ben-manes.versions") version "0.53.0" id("se.patrikerdes.use-latest-versions") version "0.2.19" application } group = "com.quarkdown" version = file("version.txt").readText().trim() allprojects { repositories { mavenCentral() } } subprojects { apply(plugin = "org.jetbrains.dokka") apply(plugin = "org.jlleitschuh.gradle.ktlint") apply(plugin = "com.github.ben-manes.versions") apply(plugin = "se.patrikerdes.use-latest-versions") } // Fat JAR / Distribution dependencies gradle.projectsEvaluated { dependencies { subprojects.forEach { when { it.extra.has("noRuntime") && it.extra["noRuntime"] == true -> { compileOnly(it) } else -> { implementation(it) } } } } } application { mainClass.set("com.quarkdown.cli.QuarkdownCliKt") } ktlint { version.set("1.7.1") } // Dokka dokka { dokkaPublications.html { outputDirectory.set( layout.buildDirectory .file("docs") .get() .asFile, ) } } /** * Whether [project] uses the Quarkdoc plugin, which means its documentation must be included in the distribution zip. */ fun usesQuarkdoc(project: Project): Boolean { val quarkdoc = project(":quarkdown-quarkdoc") return project.configurations .asSequence() .flatMap { it.dependencies } .filterIsInstance() .any { it.dependencyProject == quarkdoc } } val quarkdocGenerate = tasks.register("quarkdocGenerate") { group = "documentation" description = "Generates the Quarkdoc documentation for modules that include the Quarkdoc plugin." dependencies { subprojects.filter(::usesQuarkdoc).forEach { dokka(it) } } dependsOn(tasks.dokkaGenerate) } tasks.register("quarkdocGenerateAll") { group = "documentation" description = "Generates the Quarkdoc documentation for all modules." dependencies { subprojects.forEach { dokka(it) } } dependsOn(tasks.dokkaGenerate) } allprojects { fun asset(path: String): File = project(":quarkdown-quarkdoc").projectDir.resolve("src/main/resources/$path") dokka { pluginsConfiguration.html { val year = Year.now().value footerMessage.set("© $year Quarkdown") customAssets.from(*asset("assets/images").listFiles()!!) customStyleSheets.from(asset("styles/stylesheet.css")) } } } // Tasks distributions.main { contents { // The module 'libs' contains .qd library files that are saved in lib/qd. val librariesModule = project(":quarkdown-libs") into("lib/qd") { from(librariesModule.file("src/main/resources")) { include("*.qd") } } // Include the generated Dokka documentation, generated by quarkdocGenerate, // in the 'docs' directory. val dokkaOutputDir = layout.buildDirectory.file("docs") from(dokkaOutputDir) { into("docs") } } } tasks.installDist { dependsOn(quarkdocGenerate) } tasks.distZip { dependsOn(quarkdocGenerate) archiveVersion.set("") } tasks.distTar { dependsOn(quarkdocGenerate) archiveVersion.set("") } tasks.test { useJUnitPlatform() dependsOn(tasks.ktlintCheck) } tasks.named("startScripts") { classpath = files("lib/*") // Fixes the 'Input line is too long' error on Windows. // Prepends subscripts to the generated start scripts. doLast { val dir = file("scripts") val scripts = sequenceOf("bootstrap") scripts.forEach { scriptName -> val unixPrefix = dir.resolve("$scriptName.sh").readText() + "\n" val windowsPrefix = dir.resolve("$scriptName.bat").readText() + "\n" unixScript.writeText("#!/bin/sh\n\n" + unixPrefix + unixScript.readText()) windowsScript.writeText("@echo off\n\n" + windowsPrefix + windowsScript.readText()) } } } tasks.wrapper { gradleVersion = "8.3" distributionType = Wrapper.DistributionType.ALL } tasks.withType { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } tasks.register("printVersion") { doLast { println(project.version) } } // Dependency updates allprojects { tasks.dependencyUpdates { rejectVersionIf { Regex("[.-](alpha|beta|rc|cr|m|preview|b|ea)", RegexOption.IGNORE_CASE) in candidate.version } } tasks.useLatestVersions { updateBlacklist = listOf( "org.jetbrains.kotlin", "org.jetbrains.dokka", ) } } ================================================ FILE: demo/code/Point.java ================================================ public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } } ================================================ FILE: demo/csv/people.csv ================================================ "Username","Favorite food","Favorite beverage" "john","Chicken","Orange juice" "iamgio","Pasta","Iced tea" "daniel","Sushi","Beer" ================================================ FILE: demo/demo.qd ================================================ .docname {Quarkdown demo} .docauthor {iamgio} .doctype {slides} .doclang {English} .theme {darko} layout:{minimal} .autopagebreak maxdepth:{2} .footer .docauthor **.docname** [GitHub](https://github.com/iamgio/quarkdown) .column !(_x100)[Quarkdown logo](img/banner.svg) Markdown with superpowers # Versatile **One source code, many use cases.** Export your Quarkdown project as: .function {poweredby} credits: .text {powered by .credits} size:{tiny} variant:{smallcaps} - .fragment beautiful slides for your next presentation, just like this one. .poweredby {[reveal.js](https://revealjs.com/)} - .fragment a distinguished book. .poweredby {[paged.js](https://pagedjs.org/)} - .fragment a plain document. .fragment .whitespace *...in just one line, within your code.* .row alignment:{center} gap:{5mm} ``` .doctype {slides} ``` ``` .doctype {paged} ``` ``` .doctype {plain} ``` # Same foundations... .var {downarrow} .container padding:{6px 0 16px 0} .text {↓} weight:{bold} size:{large} .var {rightarrow} .text {->} weight:{bold} size:{large} Quarkdown's base features follow [**CommonMark**](https://spec.commonmark.org/) and [**GitHub Flavored Markdown**](https://github.github.com/gfm/) specifications, so that you'll feel at home typing code you're comfortable typing. .function {sourceresult} shrinkvertical? animated? horizontal? source: .node --- .var {snippet} .code {markdown} .source .var {ishorizontal} {.horizontal::otherwise {no}} .var {output} .container alignment:{center} fullwidth:{yes} .downarrow .var {voffset} {.shrinkvertical::ifpresent {@lambda .1::multiply {-1}}::otherwise {0}} .container textalignment:{center} margin:{.voffset 0 0 0} fullwidth:{yes} .source .ifnot {.ishorizontal} .snippet .let {.animated::otherwise {no}} .if {.1} .fragment .output .ifnot {.1} .output .if {.ishorizontal} .row alignment:{spacearound} .snippet .rightarrow .container textalignment:{center} width:{50%} .source .sourceresult shrinkvertical:{35} animated:{yes} ### Diving into _Quarkdown_ Somebody once said: > Write once, run **anywhere**. # ...brand-new everything Go ahead to discover Quarkdown's capabilities. # Functions Call any function from the extensive [standard library](https://github.com/iamgio/quarkdown/tree/main/quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib) from your Markdown code. ```markdown .somefunction {argument1} {argument2} Body argument ``` .fragment Or define your own: ```markdown .function {greet} to from: **Hello, .to** from .from! .greet {world} from:{Giorgio} ``` .fragment .text {**Hello, world** from Giorgio!} size:{small} ## Full layout control .sourceresult shrinkvertical:{50} .row alignment:{center} gap:{1cm} !(150x150)[Quarkdown](img/icon.png) .column cross:{start} .text {**Quarkdown is on GitHub!**} size:{large} variant:{smallcaps} https://github.com/iamgio/quarkdown ## Multi-file project .sourceresult shrinkvertical:{40} .include {sources/other.qd} .whitespace .fragment > Tip: functions declared in included files are imported as well. > Use it to your advantage to make awesome libraries! ## File data: code .sourceresult The constructor of the class `Point` is as follows: .code {java} .read {code/Point.java} lines:{5..8} ## File data: CSV .sourceresult .csv {csv/people.csv} ## File data: Mermaid diagrams .sourceresult .mermaid .read {mermaid/flow.mmd} ## Math .sourceresult .var {radius} {8} If we try to calculate the **surface** of a circle of **radius .radius**, we'll find out it's **.pow {.radius} to:{2}::multiply {.pi}::truncate {2}** ## Charts .sourceresult horizontal:{yes} .var {n} {100} .xychart .repeat {.n} .1::pow {2}::divide {100} .repeat {.n} .1::logn::multiply {10} <<< .column gap:{16px} ## Scripting {#scripting-basic} .text {Basic algorithm} size:{small} .sourceresult .row alignment:{spacearound} .repeat {10} n: .if {.n::iseven} **.n** is even <<< .column gap:{12px} ## Scripting {#scripting-fibonacci} .text {Fibonacci sequence} size:{small} .sourceresult shrinkvertical:{25} .var {t1} {0} .var {t2} {1} .table .foreach {0..8} n: | $ F_{.n} $ | |:----------:| | .t1 | .var {tmp} {.sum {.t1} {.t2}} .t1 {.t2} .t2 {.tmp} ## Document customization --- .container fullwidth:{yes} .grid columns:{2} gap:{1cm} **Metadata** ```markdown .docname {Quarkdown demo} .docauthor {iamgio} .doctype {slides} .doclang {english} ``` **Layout and aesthetics** ```markdown .theme {darko} layout:{minimal} .pageformat {A4} orientation:{landscape} .slides transition:{zoom} speed:{fast} ``` **Fixed content on each page** ```markdown .footer .docauthor **.docname** [GitHub](https://github.com/iamgio/quarkdown) ``` ## Intuitive error handling .sourceresult shrinkvertical:{30} .divide {x} by:{.pi} .grid columns:{3} alignment:{right} Hello, world! # Enhanced base Markdown **Not just functions.** The Quarkdown flavor introduces new features to the standard Markdown syntax. ## TeX formulas .sourceresult Let $ F(u) $ be the ***Fourier Transform*** of the function $ f(x) $: $ F(u) = \int^{+\infty}_{-\infty} f(x) e^{-i 2\pi x} dx $ .poweredby {[KaTeX](https://katex.org)} ## Alerts .sourceresult shrinkvertical:{25} > Note: did you know Quarkdown is a flexible tool to generate modern documents? > Tip: you might want to try it out. Feedback and suggestions are welcome! > Warning: it's a young project and some things may change in the near future. ## Footnotes (standard) .sourceresult Quarkdown[^qd] supports footnotes[^notes]. Take advantage of footnotes[^notes] to dive deeper into concepts. [^qd]: A modern *typesetting system*. [^notes]: This is what a footnote looks like. ## Footnotes (extension) .sourceresult Quarkdown[^: A modern *typesetting system*.] supports footnotes[^notes2: This is what a footnote looks like.]. Take advantage of footnotes[^notes2] to dive deeper into concepts. ## Quotation source .sourceresult > To be, or not to be, that is the question. > - William Shakespeare, Hamlet ## Image size .var {sizedimages} <\!-- 200px width, auto height --> !(200)[Quarkdown](img/banner.png) <\!-- auto width, 2cm height --> !(_*2cm)[Quarkdown](img/banner.png) <\!-- 2-inch width, 2-centimeter height --> !(2in*2cm)[Quarkdown](img/banner.png) --- .code {markdown} .sizedimages .downarrow .row alignment:{spaceevenly} cross:{center} .sizedimages ## Labeled figure .sourceresult shrinkvertical:{30} !(150x_)[Quarkdown](https://iamgio.eu/quarkdown/img/icon-light.png "The Quarkdown icon") ## Color preview .sourceresult The color magenta's hexadecimal representation is `#FF00FF`. The following are equivalent: - `rgb(255, 0, 255)` - `hsl(300, 100, 50)` - `hsv(300, 100, 100)` # It's a wrap You've seen the **key features** of Quarkdown - the door to making stunning documents is now open. The project is [open source](https://github.com/iamgio/quarkdown), still in development and **open to feedback and contributions**! .box The **source code** of this presentation is available [**here**](https://github.com/iamgio/quarkdown/tree/main/demo). Future plans include tutorials, wikis and docs. But for now, happy coding! .tableofcontents maxdepth:{2} ================================================ FILE: demo/mermaid/flow.mmd ================================================ flowchart LR A([Start]) --> B[Enter username and password] B --> C{Correct?} C -- Yes --> D[Redirect to dashboard] C -- No --> E[Show error message] D --> F([END]) E --> F ================================================ FILE: demo/sources/other.qd ================================================ ### Hello there! This content was **included from another source**. ================================================ FILE: docs/_nav.qd ================================================ ###! Getting started - [Quickstart](quickstart.qd) ###! Documentation - [Stable ↗](https://quarkdown.com/docs/quarkdown-stdlib/) - [Latest build ↗](https://quarkdown.com/docs/latest/quarkdown-stdlib/) ###! Functions - [Syntax of a function call](syntax-of-a-function-call.qd) - [Chaining calls](syntax-of-a-function-call.qd#chaining-calls) - [Block vs. inline calls](syntax-of-a-function-call.qd#block-vs-inline-function-calls) - [Declaring functions](declaring-functions.qd) - [Dynamic typing](typing.qd) - [Localization](localization.qd) ###! Document setup - [Document metadata](document-metadata.qd) - [Document types](document-types.qd) - [Theme](themes.qd) - [Fonts](font-configuration.qd) - [Page format](page-format.qd) - [Multi-column layout](multi-column-layout.qd) - [Page margin content](page-margin-content.qd) - [Page counter](page-counter.qd) - [Persistent headings](persistent-headings.qd) - [Numbering](numbering.qd) - [Paragraph style](paragraph-style.qd) - [Caption position](caption-position.qd) - [Table of contents](table-of-contents.qd) - [Bibliography](bibliography.qd) - [Footnotes](footnotes.qd) ###! Markdown enhancements - [Figures](figure.qd) - [Image size](image-size.qd) - [TeX formulae](tex-formulae.qd) - [Table caption](table-caption.qd) - [Code caption](code-caption.qd) - [Headings](headings.qd) - [Decorative headings](headings.qd#decorative-headings) - [Alerts (quote types)](quote-types.qd) - [Quotation source](quotation-source.qd) - [Cross-references](cross-references.qd) - [Page breaks](page-break.qd) - [Automatic break](page-break.qd#automatic-break) - [Text symbols](text-symbols.qd) - [Icons](icons.qd) - [Emojis](emojis.qd) ###! Layout - [Stacks: rows, columns, grids](stacks.qd) - [Container](container.qd) - [Align](align.qd) - [Float](float.qd) - [Figure](custom-figure.qd) - [Clip](clip.qd) - [Box](box.qd) - [File tree](file-tree.qd) - [Collapsible](collapsible.qd) - [Landscape](landscape-content.qd) - [Whitespace](whitespace.qd) ###! Multi-file projects - [Including other Quarkdown files](including-other-quarkdown-files.qd) - [Referencing other Quarkdown files](subdocuments.qd) - [Including vs. referencing](inclusion-vs-subdocuments.qd) - [Importing external libraries](importing-external-libraries.qd) ###! Variables & scripting - [Variables](variables.qd) - [Optionality](none.qd) - [Math](math.qd) - [Conditional statements](conditional-statements.qd) - [Loops](loops.qd) - [Let](let.qd) - [Destructuring](destructuring.qd) ###! Data & tables - [Table manipulation](table-manipulation.qd) - [Table generation](table-generation.qd) - [Text content from file](file-data.qd#file-text-content) - [Table from CSV data](file-data.qd#table-from-csv) ###! Charts & diagrams - [XY chart](xy-chart.qd) - [Mermaid diagrams](mermaid-diagrams.qd) ###! Text & code - [Advanced text formatting](text.qd) - [Advanced code block](code.qd) - [Explicit line breaks](line-breaks.qd) - [TeX macros](tex-macros.qd) ###! Slides - [Slides configuration](slides-configuration.qd) - [Interactive fragment](slides-fragment.qd) - [Speaker notes](slides-speaker-notes.qd) ###! Native content - [HTML](html.qd) - [CSS](css.qd) ###! Value types - String - Number - [Markdown content](markdown-content.qd) - [Boolean](boolean.qd) - [None](none.qd) - [Enumeration entry](enumeration-entry.qd) - [Iterable](iterable.qd) - [Dictionary](dictionary.qd) - [Range](range.qd) - [Lambda](lambda.qd) - [Size(s)](sizes.qd) - [Color](color.qd) - [Dynamic](typing.qd) ###! Built-in libraries - [Docs: documentation projects](docs-library.qd) - [Paper: abstract, definitions, theorems](paper-library.qd) ###! Resources & logging - [Media storage](media-storage.qd) - [Logging](logging.qd) ###! CLI tools - [Compiler](cli-compiler.qd) - [Exporting to PDF](pdf-export.qd) - [Project creator](cli-project-creator.qd) - [Webserver](cli-webserver.qd) ###! Inside Quarkdown - [Quarkdown's pipeline](pipeline.qd) - [Lexing](pipeline---lexing.qd) - [Parsing](pipeline---parsing.qd) - [Function call expansion](pipeline---function-call-expansion.qd) - [Tree traversal](pipeline---tree-traversal.qd) - [Rendering](pipeline---rendering.qd) - [Post-rendering](pipeline---post-rendering.qd) - [How does live preview work?](inside-live-preview.qd) ================================================ FILE: docs/_setup.qd ================================================ .doclang {English} .theme {galactic} layout:{hyperlegible} .numbering - example: 1 .html {.read {assets/analytics.html}} .css {.read {assets/style.css}} .var {repourl} {https://github.com/iamgio/quarkdown} .function {repolink} content href: .text {.content} url:{.repourl/.href} .function {docslink} href: .html {docs ↗} .function {example} type? content: .numbered key:{example} num: .box {Example .num} type:{.type::otherwise {tip}} .content .function {quarkdownoutput} content: .container classname:{quarkdown-output} fullwidth:{yes} .content .function {exampleoutput} output prelude? type? quarkdowncode: .example type:{.type} .if {.prelude::isnone::not} .prelude .code lang:{markdown} linenumbers:{no} .quarkdowncode .container classname:{quarkdown-output} fullwidth:{yes} .output .function {examplemirror} prelude? type? quarkdowncode: .exampleoutput output:{.quarkdowncode} prelude:{.prelude} type:{.type} quarkdowncode:{.quarkdowncode} .pagemargin {topleft} .row gap:{12px} [![Quarkdown](assets/banner-light.svg)](https://quarkdown.com) .read {../version.txt}::text classname:{version} url:{.repourl/releases/tag/latest} .pagemargin {topright} .row [Docs](/docs) --- [.icon {github}](https://github.com/iamgio/quarkdown) ================================================ FILE: docs/align.qd ================================================ .docname {Align} .include {docs} The **`.align`** block function sets the horizontal alignment of content, including multiline text alignment. The primary parameter accepts the alignment type: `start`, `center`, or `end`. For convenience, **`.center`** is a shorthand for `.align {center}`. .examplemirror .align {end} #! My document .loremipsum .examplemirror .center #! My document .loremipsum ================================================ FILE: docs/assets/analytics.html ================================================ ================================================ FILE: docs/assets/people.csv ================================================ "Username","Birth year","Favorite food","Favorite drink" "john","1992","Chicken","Orange juice" "iamgio","2002","Pasta","Iced tea" "daniel","1986","Sushi","Beer" ================================================ FILE: docs/assets/people2.csv ================================================ Name, Favorite drink, Age *(as of 2026)* Alice, Coffee, .subtract {2026} {1995} Bob, *Pepsi*, .subtract {2026} {2001} ================================================ FILE: docs/assets/point.ts ================================================ export class Point { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } } ================================================ FILE: docs/assets/sales.csv ================================================ Year, A Sales, B Sales 2017, 85, 230 2018, 100, 190 2019, 135, 180 2020, 180, 175 2021, 240, 200 2022, 320, 250 2023, 430, 290 2024, 580, 350 2025, 800, 470 ================================================ FILE: docs/assets/style.css ================================================ .page-margin-top-left a { display: flex; } header .version { opacity: 0.6; transform: translateY(2px); } .quarkdown-output { padding: 12px 16px; border-radius: 8px; background-color: var(--box-header-background-color); } .quarkdown-output:empty::after { content: "(No output)"; color: var(--qd-main-color-muted); } .quarkdown-output :is(h1, h2, h3, h4, h5, h6) { padding: 0 !important; border: none !important; } .quarkdown-output :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0 !important; } .quarkdown-output .file-tree:only-child { margin-top: 0 !important; margin-bottom: 0 !important; } .quarkdown-output img[src*="sky"] { max-width: 50%; } .box:has(.quarkdown-output) pre { margin-bottom: 16px !important; } ================================================ FILE: docs/bibliography/file.bib ================================================ @article{einstein, author = "Albert Einstein", title = "Zur Elektrodynamik bewegter Körper. (German) [On the electrodynamics of moving bodies]", journal = "Annalen der Physik", volume = "322", number = "10", pages = "891--921", year = "1905", DOI = "http://dx.doi.org/10.1002/andp.19053221004" } @book{hawking, author = "Stephen Hawking", title = "A Brief History of Time", publisher = "Bantam Books", year = "1988", ISBN = "978-0553109535" } @book{latexcompanion, author = "Michel Goossens and Frank Mittelbach and Alexander Samarin", title = "The LaTeX Companion", year = "1993", publisher = "Addison-Wesley", address = "Reading, Massachusetts" } ================================================ FILE: docs/bibliography/source.qd ================================================ Einstein's publication .cite {einstein} in 1905 revolutionized the field of physics. Similarly, Hawking's book .cite {hawking} has had a profound impact on our understanding of cosmology and black holes. --- .bibliography {/Users/gio/Dev/qd/quarkdown/docs/bibliography/file.bib} style:{ieee} ================================================ FILE: docs/bibliography.qd ================================================ .docname {Bibliography} .include {docs} Quarkdown provides CSL-powered bibliography support for the following bibliography formats: - [BibTeX](https://www.bibtex.org) (`.bib`) - CSL JSON (`.json`) - YAML (`.yaml`/`.yml`) - EndNote (`.enl`) - RIS (`.ris`) To get started, call the **`.bibliography {file}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.bibliography/bibliography.html} function, where `file` is the path to your bibliography file, with extension. You can find some BibTeX examples [here](https://www.bibtex.com/e/entry-types/). .examplemirror .bibliography {bibliography/file.bib} ## Citations You can cite one or more entries from the bibliography using the **`.cite {key}`** function. Consider the following BibTeX entries: ```text @article{einstein, author = "Albert Einstein", ... } @book{hawking, author = "Stephen Hawking", ... } ``` You can cite them using their keys. Multiple keys can be specified as a comma-separated list to produce a single combined citation label, whose format depends on the active [citation style](#style). .examplemirror Einstein's publication .cite {einstein} in 1905 revolutionized the field of physics. Similarly, Hawking's book .cite {hawking} has had a profound impact on our understanding of cosmology and black holes. These works .cite {einstein, hawking} are foundational to modern physics. ## Style The optional `style` parameter configures the look and format of the bibliography and its citation references. It accepts a [CSL](https://citationstyles.org) (Citation Style Language) style identifier. Quarkdown ships with a selection of citation styles from the [CSL Style Repository](https://github.com/citation-style-language/styles), including `ieee` (default), `apa`, `chicago-author-date`, `nature`, `modern-language-association`, and many more. ```markdown .bibliography {bibliography.bib} style:{apa} ``` .collapse {Full list of supported styles} .code lang:{text} linenumbers:{no} .read {../quarkdown-core/csl-styles.txt} ## Title By default, the title is [localized](localization.qd) to the current locale set via `.doclang`, if supported. You can set a custom title using the `title` parameter. .examplemirror .bibliography {bibliography/file.bib} title:{My bibliography} ### Heading options The heading that precedes the bibliography can be further customized with the following parameters: | Parameter | Description | Accepts | Default | |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------|---------| | `headingdepth` | Depth of the heading that precedes the bibliography. | Integer | `1` | | `breakpage` | Whether the heading triggers an automatic [page break](page-break.qd#automatic-break). | Boolean | `yes` | | `numberheading` | Whether the heading should be [numbered](numbering.qd) and have its position tracked in the document hierarchy. | Boolean | `no` | | `indexheading` | Whether the heading should be included in the document's [table of contents](table-of-contents.qd). Implicitly enables `numberheading`. | Boolean | `no` | For example, depending on the current [auto page break](page-break.qd#automatic-break) configuration, the title may cause a page break. You can prevent this: ```markdown .bibliography {file.bib} title:{My bibliography} breakpage:{no} ``` ================================================ FILE: docs/boolean.qd ================================================ .docname {Boolean} .include {docs} Boolean values are represented by the following literals, which are **case insensitive**: | Boolean value | Literals | |---------------|---------------| | **`true`** | `true`, `yes` | | **`false`** | `false`, `no` | Using the `yes` and `no` literals is encouraged because they contribute to a more natural language flow. ```markdown .code linenumbers:{no} My code ``` ## Operators The following operator functions return a `Boolean` value: - `.not {bool}`: Negates a boolean. [Chaining](syntax-of-a-function-call.qd#chaining-calls) is recommended: `.bool::not` - `.islower {a} {than}`: Returns `true` if `a` is less than `than` - `.isgreater {a} {than}`: Returns `true` if `a` is greater than `than` - `.isequal {a} {to}`: Returns `true` if `a` equals `to` ================================================ FILE: docs/box.qd ================================================ .docname {Box} .include {docs} The **`.box`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/box.html} function creates a special box container with an inline *title* and block *content*. .examplemirror .box {Box title} Welcome to the **Quarkdown wiki**! Here you'll learn how to get started with your first document. You can omit the title: .examplemirror .box Welcome to the **Quarkdown wiki**! Here you'll learn how to get started with your first document. ## Box types A box can have a type, which defaults to `callout` if not specified. The available types are: - `callout` - `tip` - `note` - `warning` - `error` .examplemirror .box {Box title} type:{tip} This is a tip box! .box {Box title} type:{note} This is a note box! .box {Box title} type:{warning} This is a warning box! ## Automatic localization If you omit the title, set [`.doclang`](document-metadata.qd), and the locale is supported, the box title is automatically [localized](localization.qd). .examplemirror .box type:{tip} This is a tip box! .box type:{note} This is a note box! .box type:{warning} This is a warning box! > Tip: Boxes and [typed quotes](quote-types.qd) are two different ways to create typed alerts. ================================================ FILE: docs/caption-position.qd ================================================ .docname {Caption position} .include {docs} The **`.captionposition`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/captionposition.html} function sets the global position of captions relative to the elements they describe. This applies to: - Figures - [Figure Markdown extension](figure.qd) - [`.figure`](custom-figure.qd) - [Mermaid diagrams](mermaid-diagrams.qd) and derivatives such as [XY charts](xy-chart.qd) - Tables - [Table caption Markdown extension](table-caption.qd) - [Tables from CSV](file-data.qd#table-from-csv) - Code blocks - [Code caption Markdown extension](code-caption.qd) - [`.code`](code.qd) --- The primary parameter, called `default`, sets the global style for all captioned elements. Additional parameters `figures`, `tables`, and `code` override the default position for those specific element types. Each parameter is optional and accepts `top` or `bottom` values. Documents use `.captionposition {bottom}` by default. .example ```markdown .captionposition {bottom} ![Sky](sky.jpg "A nice sky.") | Name | Favorite food | Favorite beverage | |------|---------------|-------------------| | John | Chicken | Orange juice | | Jane | Pasta | Iced tea | | Joe | Sushi | Beer | "Survey results." ``` ![Default: bottom](caption-position/bottom.png) .example ```markdown .captionposition {top} ``` ![Default: top](caption-position/top.png) .example ```markdown .captionposition {bottom} tables:{bottom} ``` ![Default: bottom, tables: top](caption-position/bottom-tables-top.png) > Photo credits: [Pixabay](https://www.pexels.com/photo/blue-skies-53594/) ================================================ FILE: docs/cli-compiler.qd ================================================ .docname {CLI - Compiler} .include {docs} **`quarkdown c `** is the command for compiling a main source file into a browser-renderable output. For all available options, refer to the [README's *Compiling* section](https://github.com/iamgio/quarkdown?tab=readme-ov-file#compiling). ================================================ FILE: docs/cli-project-creator.qd ================================================ .docname {CLI - Project creator} .include {docs} The **project creator** is a CLI project wizard that makes it fast to set up a new Quarkdown project. **`quarkdown create [directory]`** launches the wizard and generates the project files in the given directory (it will be created if it does not exist). Not specifying a value for `directory` generates the files in the working directory. The following information will be prompted via standard input, unless the corresponding option is set via command-line arguments: | Data | Additional info | Corresponding option | Generated Quarkdown function | |---------------------------|----------------------------------------------------|----------------------|-----------------------------------------------| | Project name | | `--name` | [`.docname`](document-metadata.qd) | | Authors | Separated by commas | `--authors` | [`.docauthors`](document-metadata.qd#authors) | | Document type | `paged`/`slides`/`plain` | `--type` | [`.doctype`](document-metadata.qd) | | Document language | [Valid language tag or full name](localization.qd) | `--lang` | [`.doclang`](document-metadata.qd) | | Color theme (unprompted) | | `--color-theme` | [`.theme`](themes.qd) | | Layout theme (unprompted) | | `--layout-theme` | [`.theme`](themes.qd) | Additional options: - `--empty`: does not include sample initial content - `--main-file `: sets the name of the main `.qd` source file. Defaults to the name of the parent directory. ================================================ FILE: docs/cli-webserver.qd ================================================ .docname {CLI - Webserver} .include {docs} Quarkdown's webserver allows direct communication between the compiler and the browser, making [live previewing](inside-live-preview.qd) possible. You can start the server via **`quarkdown start`**. > [!IMPORTANT] > A webserver is **mandatory** in order to show *paged* documents, because of a paged.js requirement. > [!TIP] > `quarkdown c ... -p` is shorthand for `quarkdown c ... && quarkdown start -f -b default` ## Options - **`-f `** or **`--file `**: (*mandatory*) the file the server should point to. It would preferably be the output directory of the compilation. - **`-p `** or **`--port `**: the webserver's port. If unset, defaults to `8089`. - **`-b `** or **`--browser `**: optional browser to open the served page on. ================================================ FILE: docs/clip.qd ================================================ .docname {Clip} .include {docs} The **`.clip {shape}`** block function clips its content to a defined shape. Supported shapes: - `circle` .examplemirror ![Sky](assets/sky.jpg) .clip {circle} ![Sky](assets/sky.jpg) ## Figures When clipping a [figure](figure.qd), only the content is affected, leaving the caption intact: .examplemirror ![Sky](assets/sky.jpg "A blue sky.") .clip {circle} ![Sky](assets/sky.jpg "A blue sky.") ## General content Clipping works with any content, not just images. Here a [container](container.qd) is used: .examplemirror .clip {circle} .container padding:{2cm} background:{teal} #! Hello! ================================================ FILE: docs/code-caption.qd ================================================ .docname {Code caption} .include {docs} Quarkdown introduces code captions, which you can set by adding a caption directly in the code block declaration. Place the caption after the language identifier, wrapped in double quotes, single quotes, or parentheses. This syntax is the same as the image *title* attribute and [table captions](table-caption.qd). .examplemirror ```python "Fibonacci function" def fibonacci(n): if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2) ``` Equivalently, via the [`.code`](code.qd) function: .examplemirror .code lang:{python} caption:{Fibonacci function} def fibonacci(n): if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2) ## Numbering You can also **number** code blocks, to display a sequential number alongside the caption. See [Numbering](numbering.qd) for more information. ================================================ FILE: docs/code.qd ================================================ .docname {Code} .include {docs} You can create code blocks using the standard Markdown specification: either with 4-space or 1-tab indentation, or with triple backticks or tildes. .examplemirror ```javascript function greet(name) { return `Hello, ${name}!`; } ``` Quarkdown also provides a more powerful **`.code`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Text/code.html} block function. .examplemirror .code lang:{javascript} function greet(name) { return `Hello, ${name}!`; } ## `.code` vs. standard code blocks ### Content processing Standard code blocks render their content as-is without any processing. The `.code` function, on the other hand, accepts any Quarkdown string as its body parameter, which means you can **evaluate functions** before displaying their output as code. This is particularly useful when combining `.code` with [`.read`](file-data.qd) to load a code snippet from a file: .examplemirror .code .read {assets/point.ts} ### Language specification Standard fenced code blocks specify their language right after the opening delimiter, for example ` ```markdown `. The `.code` function specifies the language through the optional `lang` argument, for example `.code {markdown}` or `.code lang:{markdown}`. If unspecified, auto-detection is attempted. .examplemirror .code lang:{typescript} .read {assets/point.ts} ### Line numbers Standard code blocks always show line numbers by default. The `.code` function lets you toggle line numbers using the optional `linenumbers` [`Boolean`](boolean.qd) argument, which defaults to `yes` (equivalent to `true`). .examplemirror .code linenumbers:{no} .read {assets/point.ts} ### Focused lines The `.code` function allows you to focus on a [`Range`](range.qd) of lines, starting from `1`. Line numbers must be enabled for this feature to work. .examplemirror .code focus:{5..8} .read {assets/point.ts} ## Inline code Just as `.code` is a dynamic alternative to triple backticks (`` ``` ``), **`.codespan {text}`** is a dynamic alternative to inline backticks (`` `text` ``). This allows function calls within its content. ================================================ FILE: docs/collapsible.qd ================================================ .docname {Collapsible} .include {docs} The **`.collapse`** function creates an interactive collapsible block that users can toggle by clicking. The function requires an [inline](markdown-content.qd#inline-content) title, which is always displayed, and [block](markdown-content.qd#block-content) content, which appears when expanded. .examplemirror .collapse {A _collapsible_ block. **Click me!**} You found this hidden content. **Surprise!** You can change the initial state of the block using the optional `open` [`Boolean`](boolean.qd) argument, which defaults to `false` (collapsed). .examplemirror .collapse {A _collapsible_ block. **Click me!**} open:{yes} Not so hidden content anymore! ================================================ FILE: docs/color.qd ================================================ .docname {Color} .include {docs} You can define a `Color` value in any of the following ways: - **Hexadecimal:** `#hex` - `#4290F5` - **RGB:** `rgb(red 0-255, green 0-255, blue 0-255)` - `rgb(66, 144, 245)` - **RGBA:** `rgba(red 0-255, green 0-255, blue 0-255, alpha 0-1)` - `rgba(66, 144, 245, 0.8)` - **HSV:** `hsv(hue, saturation 0-100, value 0-100)` - `hsv(214, 73, 96)` - **HSL:** `hsl(hue, saturation 0-100, lightness 0-100)` - `hsl(214, 90, 61)` - **Named:** A [CSS3 color](https://www.w3schools.com/cssref/css_colors.php) name, case-insensitive, without dashes - `blue`, `darkorange`, `white` When you write a color in a code span (wrapped in backticks), Quarkdown automatically displays a small preview box next to it. This works for all color formats except named colors. ================================================ FILE: docs/conditional-statements.qd ================================================ .docname {Conditional statements} .include {docs} The **`.if`** function creates a conditional statement: 1. The first parameter is the condition ([`Boolean`](boolean.qd)) to evaluate. 2. The second parameter is a parameter-less [lambda](lambda.qd) that runs only if the condition is true. .examplemirror .if {yes} Hello, Quarkdown! ## Nesting The function returns the lambda's result if the condition is satisfied, or nothing otherwise. This means the function *propagates* its content up the call stack. .examplemirror .if {yes} .if {no} .if {yes} .if {yes} Hello! This behavior lets you use the function as part of any expression. For example, you can use it to conditionally include content inside layout functions, such as [stacks](stacks.qd). .examplemirror .row gap:{1cm} A .if {.iseven {3}} B C ## Negation The **`.ifnot`** function is a shorthand that inverts `.if`'s behavior, returning a value only if the condition is *not* satisfied. Quarkdown does not yet have an *else* statement, but you can emulate one using the [`.let`](let.qd) function: .examplemirror .let {.iseven {3}} condition: .if {.condition} 3 is even! .ifnot {.condition} 3 is odd! > Tip: `.ifnot {.condition}` is equivalent to `.if {.condition::not}` ================================================ FILE: docs/container.qd ================================================ .docname {Container} .include {docs} The **`.container`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/container.html} function creates a highly customizable block of content, and resets the current layout rules back to normal. ## Layout reset .examplemirror {Imagine you want to create a row with two columns, each containing a heading and some text. A beginner might try the following *wrong* approach. As explained in the [Stacks](stacks.qd) page, stack functions rely on the strict Markdown concept of a block, an isolated chunk of the document, to determine which elements they handle.} type:{warning} .row alignment:{center} gap:{1cm} ##! Left Text on left column. ##! Right Text on right column. .examplemirror {You might then try using columns. This works, but the concept is not quite right. You do not want a *layout rule*; you just want to reset the document flow back to normal. This is exactly what the container does: it groups elements together according to the natural flow of the document.} type:{warning} .row alignment:{center} gap:{1cm} .column cross:{start} ##! Left Text on left column. .column cross:{start} ##! Right Text on right column. .examplemirror {The correct way to achieve the desired layout is to use containers. Each container resets the layout rules, allowing the row to treat them as separate blocks of content.} .row alignment:{center} gap:{1cm} .container ##! Left Text on left column. .container ##! Right Text on right column. ## Styling The following optional parameters are available: | Parameter | Description | Accepts | Default | |-----------|-------------|---------|---------| | `width` | Box width constraint. | [`Size`](sizes.qd) | No constraint | | `height` | Box height constraint. | [`Size`](sizes.qd) | No constraint | | `fullwidth` | Whether to take up the parent's full width. Overridden by `width`. | [`Boolean`](boolean.qd) | False | | `foreground` | Text color. | [`Color`](color.qd) | Document's default | | `background` | Background color. | [`Color`](color.qd) | None | | `border` | Border color. | [`Color`](color.qd) | Browser's default if `borderwidth` is set, none otherwise | | `borderwidth` | Border size. | [`Sizes`](sizes.qd) | Browser's default if `border` is set, none otherwise | | `borderstyle` | Border type. | `normal`, `dashed`, `dotted`, `double` | `normal` if `border` or `borderwidth` is set, none otherwise | | `margin ` | Whitespace outside the content. | [`Sizes`](sizes.qd) | None | | `padding ` | Whitespace around the content. | [`Sizes`](sizes.qd) | None | | `radius` | Corner or border radius. | [`Sizes`](sizes.qd) | None | | `alignment` | Content alignment. | `start`, `center`, `end` | Browser's default | | `textalignment` | Text alignment. | `start`, `center`, `end`, `justify` | Browser's default | | `fontsize`, `fontweight`, `fontstyle`, `fontvariant`, `textdecoration`, `textcase` | Text transformation. See [Advanced text formatting](text.qd) for details. | | None | | `classname` | Custom CSS class name. | String | None | .examplemirror .container fullwidth:{yes} borderstyle:{dashed} padding:{1cm} fontsize:{medium} fontstyle:{italic} fontvariant:{smallcaps} This is a styled container. Fancy, isn't it? Quarkdown can truly give life to complex layouts with ease. ================================================ FILE: docs/cross-references.qd ================================================ .docname {Cross references} .include {docs} In typesetting, cross-references are references to other parts of the document, such as figures, tables, sections, and equations. In Quarkdown, you create a cross-reference using the **`.ref {id}`** function, where `id` is the cross-reference ID of the target element. The function can appear either before or after the target element. > Note: Cross-referencing works best when elements are numbered, and you have set a supported document language. > See [Numbering](numbering.qd) and [Localization](localization.qd) for details. You typically set the ID using the `{#id}` syntax. The exact location depends on the element type, as the following sections explain. ### Sections .exampleoutput {![Sections](cross-references/section-reference.png)} Once you install Quarkdown, check out .ref {getting-started} for a quick guide. ## Getting started {#getting-started} > Tip: In HTML rendering, the reference ID of headings also becomes the HTML `id` attribute, which makes them suitable for linking. ### Figures .exampleoutput {![Figures](cross-references/figure-reference.png)} The Quarkdown logo is shown in .ref {logo}. ![Logo](icon.svg "The Quarkdown icon") {#logo} ### Tables .exampleoutput {![Tables](cross-references/table-reference.png)} As shown in .ref {data}, coffee is the most popular beverage. | Person | Beverage | |---------|----------| | Alice | Tea | | Bob | Coffee | | Charlie | Coffee | {#data} .example With a [caption](table-caption.qd): ```markdown | Person | Beverage | |---------|----------| | Alice | Tea | | Bob | Coffee | | Charlie | Coffee | "Beverage preferences" {#data} ``` ### Equations .exampleoutput {![Equations](cross-references/equation-reference.png)} Einstein's famous equation is shown in .ref {energy}. $ E = mc^2 $ {#energy} .example For multi-line equations: ```markdown $$$ {#energy} E = mc^2 $$$ ``` > Tip: See [TeX Formulae](tex-formulae.qd) for more information on writing equations in Quarkdown. ### Code blocks (listings) .exampleoutput {![Code blocks](cross-references/codeblock-reference.png)} See the main function in .ref {main}. ```kotlin {#main} fun main() { println("Hello, World!") } ``` .example With a [caption](code-caption.qd): ~~~markdown ```kotlin "Hello World in Kotlin" {#main} fun main() { println("Hello, World!") } ``` ~~~ ### Custom numbered elements > The `.numbered` function is explained in detail in [Numbering](numbering.qd#custom-numbered-elements). .exampleoutput {![Custom numbered elements](cross-references/numbered-reference.png)} In Example .ref {my-example} you can see a custom numbered element. .numbered {examples} ref:{my-example} number: **Example .number:** this is a custom numbered element. ================================================ FILE: docs/css.qd ================================================ .docname {CSS} .include {docs} The **`.css`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Injection/css.html} function lets you apply CSS styles to the document. > [!WARNING] > CSS styles only apply to HTML and HTML-PDF documents. They have no effect on other output formats. > > See the [HTML](html.qd) page for more details on this topic. ```css .css body { background-color: green; } h1 { color: pink; } ``` You can also load CSS styles from a file using the [`.read`](file-data.qd) function: ```markdown .css {.read {styles.css}} ``` > [!NOTE] > Unlike [`.code`](code.qd), the `.css` function does not allow function calls inside its body argument because they would be ambiguous with CSS syntax. > > For this reason, you must inline the `.read` call as shown in the previous example. ## Custom classes You can assign custom CSS class names to specific elements using the `classname` parameter, which is available in [`.container`](container.qd) for blocks and [`.text`](text.qd) for inline elements. .examplemirror .container classname:{my-custom-class} This is a block with a custom class. - Item 1 - Item 2 - Item 3 This is an .text {inline text} classname:{my-custom-class} with a custom class. .css .my-custom-class { padding: 8px; border-radius: 8px; background: linear-gradient(to right, blue 0%, forestgreen 100%); } ## Custom reusable elements You can leverage [custom functions](declaring-functions.qd) to create reusable elements with custom classes. .examplemirror .function {mytext} content: .text {.content} classname:{my-custom-class} This is a .mytext {text} and here is .mytext {another}. ## Overriding properties If you want to *override* Quarkdown's default styles, we recommend using the **`.cssproperties`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Injection/cssproperties.html} function. This function takes a [dictionary](dictionary.qd) of strings, where each item is a `--qd-*` CSS property and its value. You can find a complete list of available properties in the [global theme source](https://github.com/iamgio/quarkdown/blob/main/quarkdown-html/src/main/scss/global.scss). Unknown properties are safely ignored. .example ```yaml .cssproperties - background-color: green - heading-color: pink - block-margin: 12px ``` .box {Why is this preferred over `\.css`?} Quarkdown's themes use CSS custom properties for more granular control and easier overrides. For instance, the same property may be applied differently depending on the document type. When you call functions like [`.pageformat`](page-format.qd) or [`.paragraphstyle`](paragraph-style.qd), they apply their effects by injecting the corresponding `--qd-*` properties. Overriding a `--qd-*` property rather than its raw CSS equivalent provides smoother control and reduces the risk of future breaking changes. ================================================ FILE: docs/custom-figure.qd ================================================ .docname {Custom figure} .include {docs} .numbering - figures: 1 The **`.figure {caption?} {body}`** function generalizes the [image figures](figure.qd) syntax extension by allowing any content to be treated as a *figure* block. This means the content: - Can have a caption - Can be numbered - see [Numbering](numbering.qd) .examplemirror .figure caption:{My caption.} .container padding:{1cm} background:{teal} Hello, world! ================================================ FILE: docs/declaring-functions.qd ================================================ .docname {Declaring functions} .include {docs} You can declare functions using the **`.function`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Flow/function.html} function (in Quarkdown, everything is a function!). It accepts two arguments: the function name and its body. The function can then be invoked as a normal function call - see [Syntax of a function call](syntax-of-a-function-call.qd). .examplemirror .function {helloworld} Hello, world! .helloworld ## Parameters The body parameter is a [lambda](lambda.qd), and each parameter of the function is a parameter of the lambda block. You can access each argument within the function body as a [variable](variables.qd), which in Quarkdown is essentially a function with no parameters. .examplemirror .function {greet} to from: Hello, .to from .from! .greet {world} {John} > Tip: You can name arguments to improve readability: > > ```markdown > .greet {world} from:{John} > ``` ### Optional parameters If a function parameter ends with a question mark `?`, it becomes optional. When you do not provide the corresponding argument, it receives the value [`None`](none.qd). .examplemirror .function {greet} to from?: Hello, .to from .from! .greet {world} .greet {world} {John} `None` provides several useful [operations](none.qd#operations), such as `.otherwise` for placeholders that emulate default parameter values. .examplemirror .function {greet} to from?: Hello, .to from .from::otherwise {unnamed}! .greet {world} .greet {world} {John} ### Block parameters Block arguments always correspond to the last parameter of a function. There is no special syntax to declare them; just define the function as usual. .examplemirror .function {myexample} title content: .box {.title} .content .myexample {Example title} This is the content of the example. ## Returning values In Quarkdown, there are no return statements. Every reached instruction becomes part of the output - see [Conditional statements](conditional-statements.qd). .examplemirror .function {myfunction} .if {.iseven {3}} A B .myfunction Functions can return any Markdown content. .examplemirror .function {greet} to from: **Hello, .to** from .from! .greet {world} from:{John} Quarkdown is [weakly typed](typing.qd), so functions can return any type of value. .examplemirror .function {area} width height: .multiply {.width} by:{.height} The area of the rectangle is **.area {4} {2}**. .examplemirror .function {isadult} age: .age::isgreater than:{18} .if {.isadult age:{20}} You're an adult! ================================================ FILE: docs/destructuring.qd ================================================ .docname {Destructuring} .include {docs} Destructuring splits a [lambda](lambda.qd) parameter into its individual components. For instance, a [pair](iterable.qd#pair) has two components, while a generic [iterable](iterable.qd) can have many. The following types support destructuring: - [`Iterable`](iterable.qd), including [`Pair`](iterable.qd#pair) - [`Dictionary`](dictionary.qd), as an iterable of pairs A value is destructured into `N` components when all of the following conditions are met: - The type supports destructuring - The lambda expects a single argument, such as with [`.foreach`](loops.qd) - You supply `N > 1` lambda parameters When Quarkdown destructures the lambda argument, it operates on the individual components rather than the element itself. ## Example: `.foreach` In this example, we define a [Dictionary](dictionary.qd) and iterate over its destructured key-value components. .examplemirror .var {mydictionary} .dictionary - a: 1 - b: 2 - c: 3 .foreach {.mydictionary} key value: **.key** has value **.value** ## Example: `.sorted` In this example, we define a [Dictionary](dictionary.qd) and iterate over its destructured key-value components using `.foreach`, but only after sorting its entries by value using [`.sorted`](iterable.qd#operations), which takes a lambda that defines the ordering criteria. > Note: Remember that `@lambda` is required when declaring an [inline lambda](lambda.qd#inline-lambda). .examplemirror .var {mydictionary} .dictionary - a: 3 - b: 1 - c: 2 .foreach {.mydictionary::sorted by:{@lambda name value: .value}} name value: .name ================================================ FILE: docs/dictionary.qd ================================================ .docname {Dictionary} .include {docs} In Quarkdown, a dictionary is a collection of key-value pairs without duplicate keys. Keys are always strings, while values can be any type. The dictionary syntax resembles YAML and uses Markdown lists: ```yaml - key1: value1 - key2: value2 - key3: value3 ``` Since this syntax overlaps with [Iterable](iterable.qd) syntax, you can wrap the dictionary declaration in a **`.dictionary`** function to remove ambiguity when both types are accepted. .examplemirror prelude:{[`\.foreach`](loops.qd) accepts both iterables and dictionaries, so we explicitly create a dictionary to iterate over key-value pairs, and [destructure](destructuring.qd) them into separate variables.} .var {mydictionary} .dictionary - key1: value1 - key2: value2 - key3: value3 .foreach {.mydictionary} key value: **.key**: .value You can nest dictionaries when the function supports it, such as [`.localization`](localization.qd#creating-your-own-localized-strings): ```yaml - English: - greeting: Hello - food: Fish and chips - Italian: - greeting: Ciao - food: Pasta ``` Trailing colons before nested dictionaries are optional: ```yaml - English - greeting: Hello - food: Fish and chips - Italian - greeting: Ciao - food: Pasta ``` ## Operations You can pass a dictionary to any function that accepts an [iterable](iterable.qd#operations), where it is treated as an iterable of pairs. For a complete list of dictionary operations, see the [Dictionary documentation](https://quarkdown.com/docs/quarkdown-stdlib/com.quarkdown.stdlib.module.Dictionary) in the standard library. ================================================ FILE: docs/docs-library.qd ================================================ .docname {Docs library} .include {docs} The built-in .repolink {`docs`} {blob/main/quarkdown-libs/src/main/resources/docs.qd} library is written in Quarkdown and provides the standard structure for a [`docs` document](document-types.qd#docs-docs), with sidebars, navigation, and a table of contents. This library is the recommended way to set up a documentation project, and it is what this wiki uses. It wraps the raw `.doctype {docs}` setup into a simple, customizable include. > Note: When creating a project via [`quarkdown create`](cli-project-creator.qd), the generated project is already set up to use the `docs` library. The first step is to [import](importing-external-libraries.qd) the library: ```markdown .include {docs} ``` > Note: You do not need to call `.doctype {docs}` yourself. The library handles it. ## Project structure A project that uses the `docs` library is expected to have a similar structure: .filetree - **_setup.qd** - **_nav.qd** - **main.qd** - page-1.qd - page-2.qd - ... - **`_setup.qd`**, containing shared configuration such as [themes](themes.qd), [fonts](font-configuration.qd), custom [functions](declaring-functions.qd) and [variables](variables.qd). This file is included automatically by the library. - **`_nav.qd`** containing the navigation links that populate the page list sidebar. This file is included automatically by the library. - **`main.qd`**, the home page of the documentation, and entry point of the compilation. - Each other `.qd` file is a [subdocument](subdocuments.qd), and should include the library at the top: ```markdown .docname {My page} .include {docs} Content goes here. ``` The library takes care of the following: 1. Setting the document type to `docs` 2. Including `_setup.qd` from the project root 3. Placing the navigation from `_nav.qd` in the left sidebar via [`.pagemargin`](page-margin-content.qd) and [`.navigation`](https://quarkdown.com/docs/quarkdown-stdlib/com.quarkdown.stdlib.module.Document/navigation-container.html) 4. Placing a [table of contents](table-of-contents.qd) in the right sidebar 5. Adding a [decorative heading](headings.qd#decorative-headings) from [`.docname`](document-metadata.qd) ## Setup file The `_setup.qd` file is a good place for project-wide configuration shared across all subdocuments. .example > `_setup.qd` ```markdown .doclang {English} .theme {darko} layout:{minimal} .pagemargin {topright} [GitHub](https://github.com/iamgio/quarkdown) ``` ## Navigation file The `_nav.qd` file contains the links that appear in the page list sidebar. It consists of a list of links to subdocuments, optionally grouped by [decorative headings](headings.qd#decorative-headings): .example > `_nav.qd` ```markdown ###! Getting started - [Quickstart](quickstart.qd) ###! Topics - [Page 1](page-1.qd) - [Page 2](page-2.qd) - [Section A](page-2.qd#section-a) ``` The current page is automatically highlighted in the sidebar. ## Customization By default, the page list appears in the left sidebar and the table of contents in the right sidebar. You can swap or change these positions by overriding two variables in your `_setup.qd` file: | Variable | Default | Description | |--------------------|------------|---------------------------------------------------------------------| | `pagelistposition` | `lefttop` | [Position](page-margin-content.qd) of the page list sidebar | | `tocposition` | `righttop` | [Position](page-margin-content.qd) of the table of contents sidebar | .example > `_setup.qd` ```markdown .theme {darko} .pagelistposition {righttop} .tocposition {lefttop} ``` ## Orphan pages Note that, by design, Quarkdown does not compile orphan source files. In order to be compiled as a [subdocument](subdocuments.qd), a source file must be referenced by another file, be it in `_nav.qd` or in the content of another page. ================================================ FILE: docs/document-metadata.qd ================================================ .docname {Document metadata} .include {docs} You can set document information using the following functions. By convention, you should call these functions at the beginning of your Quarkdown source. | Name | Accepts | Default | |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| | `.docname` | The name of the document. | `Untitled Quarkdown Document` | | `.docdescription` | A short description of the document. | (empty) | | `.docauthors` | The authors of the document. | (empty) | | `.dockeywords` | Keywords associated with the document, as an [iterable](iterable.qd). | (empty) | | `.doctype` | Type of output document: `plain`, `slides`, `paged`, `docs`. See [Document types](document-types.qd) for more information. | `plain` | | `.doclang` | The language of the document: either a case-insensitive English full name (such as `English`, `Italian`, `French (Canada)`) or an IETF BCP 47 language tag (such as `en`, `it`, `fr-CA`). .br This setting defines the target locale for content localization, locale-specific styles like Chinese fonts for Chinese locales, and the HTML `lang` attribute for hyphenation and accessibility. See [Localization](localization.qd) for more. | (none) | ## Modify-or-echo All these functions have a *modify or echo* behavior: if you call them without an argument, they return the previously set value: .exampleoutput {This document is named **Quarkdown**!} .docname {Quarkdown} This document is named **.docname**! ## Authors The `.docauthors` function sets the document's authors. ```markdown .docauthors - John Doe - Jane Doe ``` The function takes a [Dictionary](dictionary.qd) as its value, where each key is the author's name and values are nested dictionaries containing author information. There are no constraints on the information entries you can use. ```markdown .docauthors - John Doe - email: johndoe@example.com - website: example.com - Jane Doe - email: janedoe@example.com - company: GitHub ``` Calling the function without arguments returns the dictionary itself, which you can iterate over to display authors according to a custom layout. .example ```markdown .foreach {.docauthors} name info: .row gap:{1cm} **.name** .get {email} from:{.info} ``` .quarkdownoutput .row gap:{1cm} **John Doe** johndoe@example.com .row gap:{1cm} **Jane Doe** janedoe@example.com ### Single author If the document has just one author, and you don't need additional information, use **`.docauthor {name}`** as a shorthand. Whether you define the author using `.docauthors` or `.docauthor`, calling `.docauthor` without arguments returns the author's name. ```markdown .docauthor {John Doe} ``` ## Keywords The `.dockeywords` function sets the document's keywords as an [iterable](iterable.qd). When exporting the document as HTML, these keywords are used for search engine optimization (SEO). ```yaml .dockeywords - markdown - typesetting - papers ``` ================================================ FILE: docs/document-types.qd ================================================ .docname {Document types} .include {docs} The [`.doctype`](document-metadata.qd) function defines the type of document based on different use cases. Each document type offers its own set of features and behaviors tailored to specific output formats. ## Plain (`plain`) A plain document has a linear layout with no page breaks, allowing content to flow continuously from top to bottom. Thanks to its responsive design, this type works well for websites and knowledge management documents, resembling the style of tools such as Notion or Obsidian. The document consists of three main elements: - A central content area - Two side margin areas !(1000)[Plain](document-types/plain.png) ### Margin content [Page margin content](page-margin-content.qd) appears in a fixed position that remains visible as you scroll. ### Footnotes [Footnotes](footnotes.qd) appear as sidenotes in this document type, positioned in the right margin area next to their first reference. On mobile devices, footnotes appear at the end of the page instead. ### PDF When you export to PDF, the output artifact contains a single page that fits the entire content. ## Paged (`paged`) A paged document follows a traditional layout with content divided into separate pages. Page breaks occur either [explicitly](page-break.qd) through manual insertion or implicitly when content exceeds the available space. !(1000)[Paged](document-types/paged.png) ### Margin content [Page margin content](page-margin-content.qd) appears on each page in a dedicated area. ### Footnotes [Footnotes](footnotes.qd) appear at the bottom of the page where their first reference occurs, in a dedicated area. ## Slides (`slides`) A slides document is designed for presentations, with a layout optimized for displaying content one slide at a time. Unless specified by the layout [theme](themes.qd), slides center their content horizontally. Slide breaks occur only through [explicit insertion](page-break.qd). !(1000)[Slides](document-types/slides.png) ### Margin content [Page margin content](page-margin-content.qd) appears on each slide without a dedicated area, which means it could potentially overlap the actual content. ### Footnotes [Footnotes](footnotes.qd) appear at the bottom of the slide where their first reference occurs, without a dedicated area. ### PDF When you export to PDF: - Each slide becomes a separate page in the output artifact - Each [fragment](slides-fragment.qd) state appears on its own page ## Docs (`docs`) A docs document is designed for technical documentation, featuring a structured navigation-first layout with sidebars, a header with a search bar, and navigation buttons in the footer. This type is ideal for creating wikis, guides, and reference materials. The wiki you are currently reading is a docs document! Docs documents leverage [subdocuments](subdocuments.qd), and include a lightweight, precise and client-side search functionality that allows finding content within the entire documentation set. ### Margin content - `top*` margin content appears in the top header area - `left*` margin content appears in the left sidebar area - `right*` margin content appears in the right sidebar area - `bottom*` margin content appears in the bottom footer area ### Footnotes [Footnotes](footnotes.qd) appear at the bottom of the page, in a dedicated area. ================================================ FILE: docs/emojis.qd ================================================ .docname {Emojis} .include {docs} Quarkdown documents support emojis through direct insertion or shortcode functions. ## Direct insertion You can insert emoji characters directly into your source code: .examplemirror Quarkdown's logo is not a planet 🪐 I love Quarkdown! 😍 ## Shortcode function You can use the `.emoji` .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Emoji/emoji.html} function with a shortcode: .examplemirror Quarkdown's logo is not a planet .emoji {ringed-planet} I love Quarkdown! .emoji {heart-eyes} > [!NOTE] > During compilation, the first call to `.emoji` in a document loads the entire emoji set, which may slightly increase compilation time. Subsequent calls are faster. For a complete list of supported shortcodes, refer to the [Emoji Cheat Sheet](https://quarkdown.com/docs/emoji-list/). ### Variants | Type | Result | Code | |---|:---:|---| | Simple shortcode | 😉 | `.emoji {wink}` | | One skin tone | 👋🏾 | `.emoji {waving-hand~medium-dark}` | | Two skin tones | 🧑🏼‍🤝‍🧑🏾 | `.emoji {people-holding-hands~medium-light,medium-dark}` | ### Credits Shortcode support is provided by [Emoji.kt](https://github.com/kosi-libs/Emoji.kt). Thanks! ================================================ FILE: docs/enumeration-entry.qd ================================================ .docname {Enumeration entry} .include {docs} An **enumeration entry** is an input value that matches the name of an element from an enumeration. Enumerations cannot be created from the Quarkdown language, so the term *enumeration* refers to a native JVM `enum`. This value type is common among function parameters in the standard library. From the user's perspective, these values are just strings. The difference occurs at invocation time, where Quarkdown throws an error if the value does not match any of the available entries. Name matching follows these rules: - **Case-insensitive**: `normal` is equivalent to `NORMAL` - **Spacing**: words in native entries are separated by underscores (`_`), which you can omit. For example, `spacebetween` is equivalent to `space_between` ## Examples [Page format](page-format.qd): ```html .pageformat {A4} ``` [Stacks](stacks.qd): ```html .row alignment:{center} ... ``` [Box](box.qd): ```html .box type:{warning} ... ``` ================================================ FILE: docs/figure.qd ================================================ .docname {Figure} .include {docs} Quarkdown introduces the concept of **figure**, which is missing in base Markdown. A figure wraps an image and centers it horizontally. When a paragraph contains only a single image (in other words, the image is isolated from other content), Quarkdown automatically converts it into a figure. .examplemirror Lorem ipsum dolor sit amet, consectetur adipiscing elit. ![Icon](assets/icon.svg) Lorem ipsum dolor sit amet, consectetur adipiscing elit. > Tip: The [image size](image-size.qd) feature works on figures as well. ## Caption If the image contains a *title* attribute (wrapped in double quotes, single quotes, or parentheses), Quarkdown displays it as a caption. .examplemirror ![Icon](assets/icon.svg "The Quarkdown icon.") > Tip: Figures can be **numbered**. See [Numbering](numbering.qd) for more information. ================================================ FILE: docs/file-data.qd ================================================ .docname {File data} .include {docs} Quarkdown provides functions to retrieve information from files. > [!NOTE] > The following functions accept a `path` parameter, which can be either a path relative to the main source file's location or an absolute path. Use a slash (`/`) as the path separator, regardless of the operating system. ## File text content The **`.read {path}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Data/read.html} function returns the string content of the specified file. An optional `lines` parameter of type [`Range`](range.qd) selects a specific range of lines (inclusive, starting from 1). An invalid or out-of-bounds range causes an error. If you do not provide a range, Quarkdown reads the entire file. ```markdown .read {myfile.txt} lines:{3..8} ``` Open ranges work as follows: - If the range is open on the left end (`..N`), Quarkdown reads from the beginning of the file to line `N`. - If the range is open on the right end (`N..`), Quarkdown reads from line `N` to the end of the file. - If the range is open on both ends (`..`), Quarkdown reads the entire file. .examplemirror {`\.read` is particularly useful in combination with functions such as [`\.code`](code.qd), [`\.mermaid`](mermaid-diagrams.qd) and [`\.css`](css.qd) to load code snippets from external files.} .code .read {assets/point.ts} ## Listing files in a directory The **`.listfiles {path} {sortby?} {order?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Data/listfiles.html} function returns an [iterable](iterable.qd) of the files in a directory. The result is unordered by default, but you can sort it by name or date. Like any other collection, you can iterate over the result or supply it to other functions. For example, you can perform automatic bulk inclusions via [`.includeall`](including-other-quarkdown-files.qd): ```markdown .includeall {.listfiles {somedirectory} sortby:{name}} ``` ## Getting a file name The **`.filename {path} {extension?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Data/filename.html} function returns the file name from a given file path. The optional `extension` boolean parameter controls whether the file extension is included in the result. .examplemirror .filename {assets/point.ts} extension:{no} ## Table from CSV The **`.csv {path} {mode?} {caption?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Data/csv.html} function loads a table from a CSV file. The first row of the CSV file always serves as the header row. Tables loaded from CSV can also be manipulated. See [*Table manipulation*](table-manipulation.qd) for more information. .examplemirror .csv {assets/people.csv} You can also provide a caption. .examplemirror .csv {assets/people.csv} caption:{People data.} The `mode` parameter controls how the CSV file is parsed. It defaults to `plain`, which treats all cell content as plain text. If set to `markdown`, Quarkdown parses cell content as inline Quarkdown source code, allowing formatting, rich content, and inline function calls within the CSV. ```markdown Name, Favorite drink, Age *(as of 2026)* Alice, Coffee, .subtract {2026} {1995} Bob, *Pepsi*, .subtract {2026} {2001} ``` .examplemirror .csv {assets/people2.csv} mode:{markdown} ================================================ FILE: docs/file-tree.qd ================================================ .docname {File Tree} .include {docs} The **`.filetree`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.MiscElements/fileTree.html} function creates a visual file tree from a standard Markdown list. Each inline item becomes a file, and each nested list becomes a directory. .examplemirror .filetree - src - components - Button.ts - Card.ts - index.ts - README.md ## Ellipsis An item with `...` as its text is rendered as an ellipsis, representing omitted content in the tree. .examplemirror .filetree - src - main.ts - ... - LICENSE - README.md ## Highlighting entries Wrapping an entry's name in **bold** (`**...**`) highlights it, useful for drawing attention to specific files or directories. .examplemirror .filetree - **src** - main.ts - utils.ts - LICENSE - **README.md** ================================================ FILE: docs/float.qd ================================================ .docname {Float} .include {docs} The **`.float {alignment}`** function transforms any content into a floating element that breaks the normal flow and allows subsequent content to wrap around it. The `alignment` parameter accepts `start` or `end`. .examplemirror .float {start} !(70%)[Icon](assets/icon.svg) .loremipsum In addition to images, you can make any other content float. .examplemirror .float {end} .box {Hello} type:{warning} Floating! .loremipsum ================================================ FILE: docs/font-configuration.qd ================================================ .docname {Font configuration} .include {docs} The **`.font`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/font.html} function overrides the global font configuration. ## Font family parameters You can load a font family from any of the following sources (case-sensitive): - **File**: `path/to/font.ttf` - **URL**: `https://example.com/font.ttf` - **System fonts**: `Arial`, `Times New Roman` - **Google Fonts**: `GoogleFonts:Roboto`, `GoogleFonts:Noto Sans` The function provides the following optional parameters for setting custom font families: - **`main`**: font family for all text content. This also applies to headings if the current theme does not specify a different font for them. - **`heading`**: font family for headings (titles). Overrides `main`. - **`code`**: font family for code blocks and code spans. Overrides `main`. Example: ```markdown .font {fonts/Inter.ttf} heading:{GoogleFonts:Poppins} code:{Courier New} ``` > Font resources are processed by the [media storage system](media-storage.qd). This ensures that, for example, HTML output includes local fonts in the output directory for increased portability. > > Note that system fonts may not be installed on all devices. While this should not affect PDF output, it can be problematic when sharing HTML output. ## Other parameters - **`size`**: base font [size](sizes.qd). Other elements, such as headings, scale accordingly. For example, `12px`. ## Fallbacks for multi-language documents Multiple `.font` calls create overlapping font configurations. Each font configuration overrides the previous one as long as it contains glyphs for the characters being rendered. Otherwise, Quarkdown uses the previous font configuration as a fallback. This is particularly useful for multi-language documents, such as documents containing both Latin and CJK (Chinese, Japanese, Korean) characters. For example, you can set a primary font for Latin characters and a fallback font for CJK characters, as shown in the following example. .exampleoutput {![Multi-language fonts](font-configuration/multi-language.png)} .font {GoogleFonts:Ma Shan Zheng} .font {GoogleFonts:Corinthia} ================================================ FILE: docs/footnotes.qd ================================================ .docname {Footnotes} .include {docs} Footnotes allow readers to reference additional information without cluttering the main content. ## Compact footnotes Quarkdown introduces a compact `[^label: definition]` syntax that allows you to define footnotes directly at their reference point. .exampleoutput {![Footnote](footnotes/basic.png)} This is a footnote reference[^first: This is the definition.], and another one[^second: This is another definition.]. Named footnotes can be referenced multiple times. .exampleoutput {![Footnote multi-reference](footnotes/multi-reference.png)} This is a footnote reference[^first: This is the definition], and another one[^first]. Definitions can include inline formatting. .exampleoutput {![Footnote formatting](footnotes/formatted.png)} This is a footnote reference[^first: This is the **definition**.] ### Anonymous footnotes When an inlined footnote does not need to be referenced elsewhere, you can omit the label to create an *anonymous* footnote. .example .code lang:{markdown} linenumbers:{no} This is a footnote reference[^: This is the definition.], and another one[^: This is another definition.]. ## Standard footnotes Quarkdown also supports the standard footnote syntax provided by many Markdown flavors. This approach is more verbose but allows for a clean separation between the footnote reference and its definition, reducing clutter in the main text. ```markdown This is a footnote reference[^1]. [^1]: This is the definition. ``` Inline formatting is also supported in the definition: ```markdown [^1]: This is the **definition**. ``` Long definitions can span multiple lines. Indentation is not significant: ```markdown [^1]: This is the definition. ``` When you have multiple footnotes, separate the definitions with at least one blank line: ```markdown This is a footnote reference[^first], and another one[^second]. [^first]: This is the definition. [^second]: This is another definition. ``` Multiple references to the same footnote label are allowed, and they all point to the same definition: ```markdown This is a footnote reference[^first], and another one[^first]. [^first]: This is the definition. ``` The footnote label can be any string, and the definition can appear anywhere in the document: ```markdown [^first]: This is the definition. This is a footnote reference[^first], and another one[^second]. [^second]: This is another definition. ``` ## Numbering Footnotes are numbered by default with decimal numbers, starting from 1. To apply a different numbering style, such as Roman numerals, use the `.numbering` function. See [*Numbering*](numbering.qd) for more information. .exampleoutput {![Footnote Roman numbering](footnotes/roman-numbering.png)} .numbering - footnotes: i Footnotes are numbered incrementally across the subdocument. Page-level numbering is not supported yet. ## Display Footnotes render differently depending on the document type. See [*Document types*](document-types.qd) for more information. ================================================ FILE: docs/headings.qd ================================================ .docname {Headings} .include {docs} The **`.heading {content} {depth}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Primitives/heading.html} function creates a heading with fine-grained control over its behavior. Unlike standard Markdown headings (`#`, `##`, etc.), this function allows explicit control over numbering, page breaks, table of contents indexing, and custom identifiers. ## Parameters | Parameter | Description | Accepts | Default | |-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|----------| | `content` | Inline content of the heading. | Inline content | Required | | `depth` | Importance level of the heading (1 for H1, 6 for H6). | Integer (1-6) | Required | | `ref` | Custom identifier for [cross-referencing](cross-references.qd). If unset, the ID is automatically generated. | String | Unset | | `numbered` | Whether the heading is [numbered](numbering.qd) and has its position tracked in the document hierarchy. Actual numbering depends on the active `.numbering` configuration. | Boolean | `yes` | | `indexed` | Whether the heading appears in the [table of contents](table-of-contents.qd) and navigation sidebar. | Boolean | `yes` | | `breakpage` | Whether the heading triggers an automatic [page break](page-break.qd#automatic-break). | Boolean | `yes` | ## Basic usage .exampleoutput {##! My heading} prelude:{This is equivalent to `## My heading` in standard Markdown.} .heading {My heading} depth:{2} ## Controlling numbering You can create headings that are not tracked by the [numbering](numbering.qd) system: ```markdown .heading {Appendix} depth:{1} numbered:{no} ``` Unlike [decorative headings](#decorative-headings) (`#!`), this approach lets you independently control whether the heading appears in the table of contents. ## Controlling table of contents indexing By default, headings appear in the [table of contents](table-of-contents.qd) and navigation sidebar of `plain` and `paged` documents. You can exclude a heading while still allowing it to be numbered: ```markdown .heading {Secret section} depth:{2} indexed:{no} ``` Conversely, you can include an unnumbered heading in the table of contents: ```markdown .heading {Acknowledgments} depth:{1} numbered:{no} indexed:{yes} ``` ## Disabling page breaks By default, headings trigger automatic page breaks (when [`.autopagebreak`](page-break.qd#automatic-break) is enabled). You can disable this for individual headings: ```markdown .heading {Continued} depth:{1} breakpage:{no} ``` ## Custom identifiers You can assign a custom identifier for [cross-referencing](cross-references.qd): ```markdown .heading {Introduction} depth:{2} ref:{intro} ``` ## Decorative headings To prevent a heading from being numbered and from appearing in the [table of contents](table-of-contents.qd), append a `!` after the last `#` sign. For example: `#!`, `##!`, `###!`, etc. .exampleoutput {![Decorative heading](numbering/decorative-heading.png)} ```markdown .center #! My document ## Introduction .loremipsum ``` > Note: A heading with all optional flags disabled via `.heading` is equivalent to a decorative heading: > > ```markdown > .heading {My decorative heading} depth:{2} numbered:{no} indexed:{no} breakpage:{no} > ``` ================================================ FILE: docs/html.qd ================================================ .docname {HTML} .include {docs} Standard Markdown specifications allow freely mixing Markdown and HTML, as they are meant to be implemented by parsers that solely rely on HTML rendering. ```markdown **Hello** world! ``` On the other hand, Quarkdown strongly encourages **target agnosticism** in order to maintain rendering consistency across all supported rendering targets. At this time, only HTML rendering is supported (note: PDF export is not a rendering target, as it works on top of HTML post-processing). There are however future plans to bring support to more targets, such as LaTeX. In that case, HTML content cannot be handled by the native target anymore. For this reason, Quarkdown dropped mixed content support and focused on covering the most frequent HTML workarounds with dedicated functions. .example - A collapsible block in standard Markdown via HTML: ```html
Title of the collapsible block Content of the collapsible block.
``` - The same in Quarkdown, using the [`.collapse`](collapsible.qd) function: ```markdown .collapse {Title of the collapsible block} Content of the collapsible block. ``` .example - A styled container in HTML: ```html
This is a styled container.
``` - The same in Quarkdown, using [`.container`](container.qd): ```markdown .container border:{black} borderwidth:{1} padding:{8} This is a styled container. ``` ## Forcing HTML injection As a last resort, if the functionality you are looking for is not supported out of the box, you might consider calling the `.html` function, which directly renders its content into the final document, as long as the rendering target is HTML. .examplemirror **Hello** .html {world}! .examplemirror .html
My HTML container
> [!WARNING] > - The rendered output is unsanitized content, possibly vulnerable. > - This approach is not target-agnostic, as other rendering targets will ignore the provided content. ================================================ FILE: docs/icons.qd ================================================ .docname {Icons} .include {docs} The **`.icon {name}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Icon/icon.html} function displays a pixel-perfect icon by its name. .examplemirror Quarkdown is on .icon {github} In HTML rendering, the [Bootstrap Icons](https://icons.getbootstrap.com/#icons) library is used. Refer to the library's icon list for all available names. > Note: No validation is performed at compile time. If an icon name does not exist in the library, it may not be rendered or may be rendered incorrectly. ================================================ FILE: docs/image-size.qd ================================================ .docname {Image size} .include {docs} While base Markdown requires HTML code to constrain the size of an image, Quarkdown introduces a syntax extension that achieves the same result more elegantly: `!(WIDTHxHEIGHT)[ALT](URL)`. .exampleoutput {!(300x100)[Icon](assets/sky.jpg)} prelude:{The following equivalent examples load `image.png` with the label `Alt` as CommonMark Markdown does, but also add a size constraint that fits the visible image into a 300px by 100px box.} !(300x100)[Alt](image.png) !(300*100)[Alt](image.png) !(300 100)[Alt](image.png) In addition to pixels (whose unit can be omitted), any other [size](sizes.qd) unit is supported. When using other units, you must use either `*` or a space as the delimiter instead of `x`. ```markdown !(5cm*35mm)[Alt](image.png) !(2.5in 1cm)[Alt](image.png) !(2.5inx1cm)[Alt](image.png) !(30%x150%)[Alt](image.png) ``` ## Automatic dimension When you set either component (width or height) to `_` (underscore), it becomes automatic, meaning that component is calculated to preserve the original aspect ratio. ### Auto height ```markdown !(300x_)[Alt](image.png) !(5cm*_)[Alt](image.png) !(2in _)[Alt](image.png) !(50% _)[Alt](image.png) ``` As a convenient shorthand, you can omit the automatic height entirely: ```markdown !(300)[Alt](image.png) !(5cm)[Alt](image.png) !(2in)[Alt](image.png) !(50%)[Alt](image.png) ``` ### Auto width ```html !(_x100)[Alt](image.png) !(_*15mm)[Alt](image.png) !(_ 2in)[Alt](image.png) ``` ================================================ FILE: docs/importing-external-libraries.qd ================================================ .docname {Importing external libraries} .include {docs} The **`.include`** function, previously seen in [*Including other Quarkdown files*](including-other-quarkdown-files.qd), can also import external **libraries**. When you download Quarkdown or build it via `distZip`, the `lib/qd` directory contains utility libraries written in Quarkdown itself. .filetree - quarkdown - lib - qd - lib.qd - ... - bin - quarkdown.jar You can import `.qd` files into a Quarkdown project via `.include {name}`, without the file extension. For example, to import `paper.qd`, use `.include {paper}`. > Note: Unlike `.include {path}`, this approach only loads declared symbols without appending Markdown content from the file. > Tip: The default library directory is `/lib/qd`. You can override this via the command-line option `-l` or `--libs`. ================================================ FILE: docs/including-other-quarkdown-files.qd ================================================ .docname {Including other Quarkdown files} .include {docs} The **`.include {file} {sandbox?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Ecosystem/include.html} function loads and evaluates an external Quarkdown source file. The parameter accepts a string that represents the **path to the target file**, which can be relative to the main source file's location or absolute. > To include external libraries, refer to [*Importing external libraries*](importing-external-libraries.qd). .example > `file.qd` > ```markdown > ### Hello Quarkdown > > This is external content. > ``` > `main.qd` > ```markdown > .include {file.qd} > ``` .quarkdownoutput ### Hello Quarkdown This is external content. > Important: Circular dependency results in an error. > [!NOTE] > > Do not confuse inclusion with [subdocuments](subdocuments.qd). > See [*Inclusion vs. subdocuments*](inclusion-vs-subdocuments.qd) for a comparison. ## Bulk include A clean approach with typesetting systems is having a main file that gathers all the different subfiles together. The `.includeall` function, which takes an [Iterable](iterable.qd) of paths, serves as a convenient shorthand for repeated `.include` calls. The following snippet is from [Mock](https://github.com/iamgio/quarkdown/blob/main/mock)'s `main.qd` file: .code lang:{markdown} .read {../mock/main.qd} lines:{6..} You can also combine the function with [`.listfiles`](file-data.qd) to automatically include all files in a directory: ```markdown .includeall {.listfiles {somedirectory} sortby:{name}} ``` ## Context sharing The `.include` function's `sandbox` parameter lets you control how much isolation the included file has from the main file. The included file always inherits the context of the main file, but you can optionally allow changes in the included file to propagate back. For these examples, consider the following files: > `file.qd` > ```markdown > .docname {New name} > > .function {greet} > name: > Hello, **.name**! > ``` > `main.qd` > ```markdown > .docname {My document} > .include {file.qd} sandbox:{} > > 1. .docname > 2. .greet {John} > ``` Here are the available options, in ascending order of isolation: ### `share` (default) Both contexts are synchronized bidirectionally. Any customization, function, variable, and other information declared in the included file will also be available in the main file, and vice versa. > Output: > > 1. New name > 2. Hello, **John**! ### `scope` Similar to `share`, but function and variable declarations do not propagate back to the main file. This is the same behavior used in nested lambda scopes, such as in [`.function`](declaring-functions.qd) and [`.foreach`](loops.qd). > Output: > 1. New name > 2. Compile error: `.greet` is not defined. ### `subdocument` The included file is completely isolated from the main file. Any changes, including metadata and layout options, do not propagate back. This is the same behavior as [subdocuments](subdocuments.qd). > Output: > > 1. My document > 2. Compile error: `.greet` is not defined. ## Use case: setting up A common use case is putting all setup function calls in a separate file. See the *Document setup* section of this wiki for all available options. > `setup.qd` > ```markdown > .docname {My document} > .docauthor {iamgio} > .doctype {slides} > .doclang {English} > .theme {darko} layout:{minimal} > > .footer > ... > ``` > `main.qd` > ```markdown > .include {setup.qd} > > # My cool document > > ... > ``` ================================================ FILE: docs/inclusion-vs-subdocuments.qd ================================================ .docname {Inclusion vs subdocuments} .include {docs} Quarkdown offers two ways to include content from other files: **inclusion** and **subdocumenting**. These approaches differ significantly in how they work and what they are best suited for. ## Inclusion [Inclusion](including-other-quarkdown-files.qd) via `.include` and `.includeall` evaluates other Quarkdown source files. The function returns the evaluation result, making it appear as if the target file's content was inserted directly in place of the function call. This is an **opaque operation** because the output contains no traces of the original file. .example > `main.qd`: > > ```markdown > Hello 1 > > .include {other.qd} > ``` > `other.qd`: > > ```markdown > Hello 2 > ``` .quarkdownoutput .html {

index.html:

} ```html

Hello 1

Hello 2

``` ### Context The execution context is ***shared*** between the main file and the included file. Any customization, function, variable, and other information declared in the main file will be available in the included file, **and vice versa**. You can optionally restrict this behavior via the [`sandbox`](including-other-quarkdown-files.qd#context-sharing) parameter. ### Circular references Circular or recursive inclusions are not allowed and will result in an error. ## Subdocuments [Subdocuments](subdocuments.qd) are independent and referenceable source files. Subdocuments render as separate resources, and Quarkdown stores links to them in a graph structure. .example > `main.qd`: > > ```markdown > Hello 1 > > [Other](other.qd) > ``` > `other.qd`: > > ```markdown > Hello 2 > ``` .quarkdownoutput .html {

index.html:

} ```html

Hello 1

Other

``` .html {

other.html:

} ```html

Hello 2

``` ### Context When evaluating subdocuments, the context is ***inherited*** from the referrer. Any customization and declaration made in the referrer will be available in the subdocument, but not the other way around. ### Circular references Each subdocument is evaluated only once, so circular and recursive references are allowed. ================================================ FILE: docs/inside-live-preview.qd ================================================ .docname {Inside live preview} .include {docs} Quarkdown's [webserver](cli-webserver.qd) enables direct communication between the compiler's CLI and the browser, which makes live previewing possible. The server exposes the following endpoints: - **`/:file`**: Serves static files relative to the target file (the `-f` option of `quarkdown start`). - **`/live/:file`**: Works similarly to the previous endpoint, but wraps HTML files in a wrapper that enables live reloading. - **`/reload`**: A WebSocket endpoint for live reloading. - Browsers connected to the `/live` endpoint listen for reload messages and refresh the page when they receive one. - The CLI connects to this endpoint and sends a message every time a compilation completes. When this happens, the server broadcasts a reload message to all connected browsers. ## Live preview wrapper When serving a file through the `/live/:file` endpoint, the server wraps HTML files in a simple [HTML wrapper](https://github.com/iamgio/quarkdown/tree/main/quarkdown-server/src/main/resources/live-preview/wrapper.html.template) that displays the content of `/:file` in an [iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe). Additionally, a small script establishes a persistent WebSocket connection to the `/reload` endpoint. This script listens for reload messages and triggers a reload of the iframe content when it receives one. .mermaid sequenceDiagram participant Browser participant Server participant CLI Browser->>Server: GET /live/:file Server-->>Browser: Live preview HTML wrapper Browser->>Server: WebSocket connect /reload CLI->>Server: WebSocket message /reload Server->>Browser: WebSocket broadcast /reload Browser->>Browser: Reload iframe content ### Double buffering When an iframe reloads, an unpleasant flickering effect can occur. To provide a smooth user experience, Quarkdown employs a technique called [double buffering](https://en.wikipedia.org/wiki/Double_buffering). For context, you encounter double buffering constantly in everyday computing: your GPU renders the next frame to an off-screen buffer while displaying the current frame. Once the next frame is ready, the buffers swap, resulting in a smooth transition without flickering. Quarkdown applies the same principle by maintaining two iframes, `A` and `B`. Suppose `A` is currently visible. When a change is detected, Quarkdown renders the updated content into `B` while `A` remains on screen. Once the rendering completes, the visible iframe switches from `A` to `B`, providing a seamless update. Before swapping the buffers, the system also preserves the scroll position from the previous frame and restores it in the new frame, so you do not lose your place in the document. ================================================ FILE: docs/iterable.qd ================================================ .docname {Iterable} .include {docs} Iterable values are ordered lists or unordered sets that you can iterate through with a [loop](loops.qd). Iterables can also be destructured. See [*Destructuring*](destructuring.qd) for more information. ## Collection An ordered or unordered Markdown list is automatically converted to an ordered collection. .examplemirror .var {letters} - A - B - C .foreach {.letters} .1::lowercase Nested collections can be represented by nested Markdown lists as well. .examplemirror .var {letters} - - A - B - - C - D - - E - F .foreach {.letters} .1::first ## Pair A pair is an iterable of two values. You can create a pair via **`.pair {first} {second}`** or retrieve one as a [Dictionary](dictionary.qd) entry. ## Dictionary When used in a function that requires an iterable, a [Dictionary](dictionary.qd) value is treated as a list of key-value pairs. ## Range An integer [`Range`](range.qd) is a valid ordered iterable value. ## Operations Assuming `myiterable` is an iterable, you can access useful operations such as `getat`, `sorted`, `average`, and many more via [function call chaining](syntax-of-a-function-call.qd#chaining-calls) as `.myiterable::operation`. For a complete list of operations, refer to the standard library's [`Collection` documentation](https://quarkdown.com/docs/quarkdown-stdlib/com.quarkdown.stdlib.module.Collection). ================================================ FILE: docs/lambda.qd ================================================ .docname {Lambda} .include {docs} A **lambda** is a block of code that maps a variable number of parameters into a single output value of any type (see the *Value types* section of this wiki). The syntax is: ```markdown param1 param2 param3: My code ``` The `param1 param2 param3:` portion is the *header* of the lambda, where you define parameter names. You can access their values as if you were dealing with [variables](variables.qd): ```markdown param1 param2 param3: The second parameter is .param2 ``` You can omit the header in these cases: - When the lambda expects 0 parameters (such as in [conditional statements](conditional-statements.qd)) - In any other case, the parameters become *implicit* and can be accessed by position via `.1`, `.2`, `.3`, etc. ```markdown The second parameter is .2 ``` Lambdas are constructs that can fork and create new **scopes**. - Nested scopes inherit properties from their parent, such as defined variables and functions. - Properties defined inside a nested scope cannot be accessed by their parent, meaning variables defined within a lambda block do not exist outside of the lambda itself. ## Inline lambda Lambdas are defined the same way whether they appear in a block or an inline argument (those wrapped in curly braces). However, for inline arguments, you must add a **`@lambda`** instruction at the beginning to help the compiler recognize that it is dealing with a lambda. ```markdown .myfunction {@lambda x y: The values are .x and .y} ``` Implicit parameters work as well: ```markdown .myfunction {@lambda The values are .1 and .2} ``` This is only needed if you access parameters of the lambda. If the evaluation is constant, you can omit `@lambda`. ## Examples ### [`.foreach`](loops.qd) ```markdown .foreach {2..5} n: The number is **.n** ``` With implicit parameter: ```markdown .foreach {2..5} The number is **.1** ``` ### [`.function`](declaring-functions.qd) The body of `.function` is a lambda that accepts a variable amount of *explicit* parameters: ```markdown .function {area} width height: .multiply {.width} by:{.height} ``` ### [`.takeif`](none.qd#operations) ```markdown .num::takeif {@lambda x: .x::equals {5}} ``` ================================================ FILE: docs/landscape-content.qd ================================================ .docname {Landscape content} .include {docs} > Warning: This feature is **experimental**. Fitting wide tables, diagrams, charts, or other horizontally expansive elements onto a vertical page can be challenging, and the content may become difficult to read when constrained to portrait orientation. To address this limitation, the **`.landscape`** function renders content in landscape orientation within a portrait page. This approach is ideal for printing or viewing resources that benefit from extra horizontal space. ```markdown .landscape Content ``` !(700)[Landscape](landscape-content/landscape.png) For comparison, the same content in portrait orientation would stretch and compress the content, reducing readability: !(700)[Portrait](landscape-content/portrait.png) ================================================ FILE: docs/let.qd ================================================ .docname {Let} .include {docs} The **`.let`** function defines a temporary variable that is accessible only within its scope. It accepts two parameters: 1. The value, of [any type](typing.qd), to assign to the scoped variable 2. A [lambda](lambda.qd) block that accepts one parameter (the given value) .examplemirror .let {.multiply {4} {2}} area: The area of the rectangle is .area. If it were a triangle, it would have been .divide {.area} by:{2}. The function returns the evaluation of the lambda, so you can use it as an expression. .examplemirror .center .let {Quarkdown} name: .uppercase {.name}, .lowercase {.name}, .capitalize {.name} The lambda block also accepts implicit positional parameters. See [*Lambda*](lambda.qd) for more information. .examplemirror .center .let {Quarkdown} .uppercase {.1}, .lowercase {.1}, .capitalize {.1} ================================================ FILE: docs/line-breaks.qd ================================================ .docname {Line breaks} .include {docs} Quarkdown follows the standard Markdown specification, so you can create soft line breaks by ending a line with two or more spaces: > In the following snippet, a whitespace character is represented by `␣` for clarity. ```markdown First line␣␣ Second line ``` This syntax, however, is not always ideal. It can be ambiguous and lead to confusion. Additionally, some text editors may unexpectedly trim trailing spaces, which removes the intended line breaks. > Some Markdown users suggest using explicit `
` HTML tags. Quarkdown, however, [discourages direct HTML injection](html.qd). As an alternative, Quarkdown provides the **`.br`** function for explicit line breaks. .examplemirror First line .br Second line .examplemirror {A newline after the function call is not mandatory.} First line .br Second line There are no special conventions regarding the preferred approach for breaking lines. You can choose any of these methods based on your personal preference. ================================================ FILE: docs/localization.qd ================================================ .docname {Localization} .include {docs} Quarkdown supports **string localization** out of the box. The first step is to set the document language via [**`.doclang {locale}`**](document-metadata.qd). Call this function among the other document metadata functions (such as `.docname`, `.docauthor`, etc.). The `locale` value can be either a case-insensitive English full name (e.g., `English`, `Italian`, `French (Canada)`) or an IETF BCP 47 language tag (e.g., `en`, `it`, `fr-CA`). ## Built-in localization Quarkdown's built-in libraries expose localization tables that localize elements such as [quote types](quote-types.qd), [numbering](numbering.qd) captions, and [table of contents](table-of-contents.qd) title. > Note: The currently supported locales are **English, Italian, German, French, Chinese, and Japanese**. > > Contributions to support new locales are welcome: > - [stdlib](https://github.com/iamgio/quarkdown/blob/main/quarkdown-stdlib/src/main/resources/lib/localization) > - [paperlib](https://github.com/iamgio/quarkdown/blob/main/quarkdown-libs/src/main/resources/paper) ## Creating your own localized strings .examplemirror {Imagine a function `\.theorem` that displays **`Theorem.`** before its content. You could define it as follows:} type:{warning} .function {theorem} **Theorem.** .theorem This is my theorem This works well for your own English document, but what if you are making a library for everyone to use? You would need to support multiple languages. This is where *localization tables* come in. The `.localization {name}` function defines a new **localization table** associated with a unique name. Its body parameter accepts a particular Markdown list that, in Quarkdown, is called a [*dictionary*](dictionary.qd). This localization dictionary exposes key-value pairs for each locale that you intend to support. The locale names follow the same rules as those from `.doclang`, meaning they can be full names or tags. As long as `.doclang` is set, you can access the localized string via `.localize {table:key}`, in this case `.localize {mylib:theorem}`. .exampleoutput {**Theorem.** This is my theorem} prelude:{The previous function would now look like this:} .localization name:{mylib} - English - theorem: Theorem - Italian - theorem: Teorema .function {theorem} **.localize {mylib:theorem}.** .theorem This is my theorem ## Extending a built-in localization table If your locale is not yet supported by Quarkdown and you are unable to contribute to the project, you can still extend the built-in localization tables for your document. When calling `.localization {name}`, an additional **`merge:{yes}`** argument causes the localization table with the given name to be extended with the new user-provided one. Any conflicting entries will be replaced by the new ones. For instance, [typed boxes](box.qd) feature a localized title by default, such as *Warning* for a warning-typed box. If the document locale is not supported, the title will be missing. To extend the built-in localization with box titles in Canadian French, use the following approach: ```yaml .localization {std} merge:{yes} - fr-CA - warning: Avertissement - error: Erreur ... ``` After that, assuming Canadian French is set in `.doclang`, the new entries will be available to the `.box` function. Built-in table names and entries are listed in this page's [*Built-in localization*](#built-in-localization). ================================================ FILE: docs/logging.qd ================================================ .docname {Logging} .include {docs} Quarkdown provides several ways to log content to standard channels, which can be useful for debugging and error handling. - **`.log {message}`** logs a message to stdout at the *info* level. This output appears when you run the compiler with `-Dloglevel=info` or a lower threshold. - **`.debug {message}`** logs a message to stdout at the *debug* level. This output appears when you run the compiler with `-Dloglevel=debug` or a lower threshold. - **`.error {message}`** throws a runtime error, which the error manager then handles according to the current mode: - By default, the error message is logged to stderr (when `-Dloglevel=error` or a lower threshold is set) and an error [box](box.qd) appears in the document. - If you launch the CLI in strict mode (`--strict`), a full stack trace is logged to stderr and the program exits. ================================================ FILE: docs/loops.qd ================================================ .docname {Loops} .include {docs} ## For-each The main type of loop is provided by the **`.foreach`** function, which accepts: 1. An [`Iterable`](iterable.qd) value 2. A single-parameter [lambda](lambda.qd) block, where the argument is the current item being iterated .examplemirror .foreach {2..4} n: The number is: **.n** The function returns an ordered iterable **collection** of the same size as the input, containing the evaluation of the lambda for each iterated value. This means the function can be used as an expression, similarly to the `map` function in many programming languages. .examplemirror {Keep in mind that `\.1` implicitly refers to the first parameter of the lambda.} .row alignment:{spacearound} .foreach {1..3} .1 Any iterable value is accepted, including Markdown lists. See [*Iterable*](iterable.qd) for all possible ways of defining an iterable value. .examplemirror .var {letters} - A - B - C .foreach {.letters} ###! .1 The letter is **.1**. The type of iterated elements is preserved. See [*Typing*](typing.qd) for more information. .examplemirror .row alignment:{spacearound} .foreach {1..5} n: .multiply {.n} by:{.n} ## Repeat The **`.repeat {times}`** function is a shorthand for `.foreach {1..times}`. .examplemirror .repeat {3} .1 ================================================ FILE: docs/main.qd ================================================ .docname {Quarkdown Wiki} .include {docs} .css h1 { opacity: 0; height: 0; margin: 0; padding: 0; } figure { margin: 72px 0; } ![Quarkdown](assets/banner-light.svg) ## Welcome to the Quarkdown Wiki Use the sidebar to explore how-to guides and all the features of Quarkdown has to offer, and learn about the inner workings of the compiler. This wiki is written in Quarkdown itself, and built on the latest stable release. You can find the source files in the .repolink {docs} {tree/main/docs} directory. ### Documentation This wiki complements the **project documentation** available at [quarkdown.com/docs](https://quarkdown.com/docs/quarkdown-stdlib). While the documentation provides comprehensive details about functions, their inputs, and their outputs, this wiki focuses on practical, user-centered guides that show how to use Quarkdown's features in real-world scenarios. When a topic has particularly relevant documentation, you will find a .docslink {quarkdown-stdlib} link that takes you directly to it. ### Other resources - .repolink {README} {blob/main/README.md#installation} covers installation and getting started. - Visit the [Discussions](https://github.com/iamgio/quarkdown/discussions) page to ask questions and share your creations. - The [quarkdown-test](https://github.com/iamgio/quarkdown/tree/main/quarkdown-test/src/test/kotlin/com/quarkdown/test) module contains complete, working examples that you can use as reference. - Since Quarkdown extends CommonMark and GFM, this wiki does not cover standard Markdown syntax. For Markdown basics, see [markdownguide.org](https://www.markdownguide.org/basic-syntax). ================================================ FILE: docs/markdown-content.qd ================================================ .docname {Markdown content} .include {docs} Many functions accept rich Quarkdown content as their argument. ## Block content Body parameters usually accept *block content*, which refers to one or more *block* elements that Quarkdown supports. This includes paragraphs, headings, code blocks, quotes, [block function calls](syntax-of-a-function-call.qd#block-vs-inline-function-calls), and more. Inner *inline* elements are processed as well. In other words, everything you can express with Quarkdown *outside* a function call will also work here. ```markdown .center # My centered title This is a paragraph in a **centered** block! > This is a _blockquote_. > > It spans over multiple sub-paragraphs. .row ... ``` > See more: [`.center`](align.qd), [`.row`](stacks.qd) ## Inline content *Inline content* strictly accepts inline data and ignores any block syntax. This includes plain text, strong (bold), emphasis (italics), code spans, links, images, [inline math](tex-formulae.qd), [inline function calls](syntax-of-a-function-call.qd#block-vs-inline-function-calls), and more. ```markdown .box {My _box_ title} ... ``` > See more: [`.box`](box.qd) Block syntax has no effect where inline content is required. In the following example, *# My box* is parsed as plain text, since headings are block elements. ```markdown .box {# My box} ... ``` Inline function calls are accepted: ```markdown .box {3 + 2 is .sum {3} {2}} ... ``` > See more: [`.sum`](math.qd) In general, everything that can appear inside a paragraph can also go into an inline argument. ================================================ FILE: docs/math.qd ================================================ .docname {Math} .include {docs} Mathematical functions provide a way to perform numeric operations. .examplemirror .var {radius} {8} If we try to calculate the **surface** of a circle of **radius .radius**, we'll find out it's **.pow {.radius} to:{2}::multiply {.pi}::truncate {2}** Handling complex math is particularly effective when combined with [function call chaining](syntax-of-a-function-call.qd#chaining-calls). The following two calls are equivalent, with the latter being more natural to read: ```markdown .truncate {.multiply {.pow {.radius} to:{2}} by:{.pi}} {2} ``` ```markdown .pow {.radius} to:{2}::multiply {.pi}::truncate {2} ``` For a complete list of available functions, refer to the standard library's [`Math` documentation](https://quarkdown.com/docs/quarkdown-stdlib/com.quarkdown.stdlib.module.Math). ================================================ FILE: docs/media-storage.qd ================================================ .docname {Media storage} .include {docs} **Media storage** is a feature that ensures all required files are present in the output directory when you export a Quarkdown project. Here is what it does: 1. Keeps track of external files (*media*) referenced in a Quarkdown document through **image** and **reference image** nodes 2. Copies each media file to the `media` directory inside the output directory 3. Updates the image nodes to point their source path to the newly created file > Source: > ```markdown > ![Image](../my-img.png) > ``` > Result: > ```html > Image > ``` Media storage is enabled by default. You can turn it off via the `--no-media-storage` flag. ## Why Take HTML as an example: images from remote URLs load effortlessly, while *local* ones (e.g., `../my-img.png`) require the image file to be present on the server at the exact same location. Imagine writing a Quarkdown document, typing `![Image](my-img.png)`, with `my-img.png` located in the same directory as your Quarkdown source. You would expect it to work, but as soon as you compile the project and open your HTML artifact, you notice the browser cannot find the file simply because it is not there. Thanks to the media storage system, all media files are carried around with your output. ## Options The storage can handle **both local and remote files**. The rules that determine which types are allowed in the storage are set by the active renderer. - When rendering to HTML, storage is enabled for local files only. - On the other hand, LaTeX (which is not yet supported but might be in the future) also requires remote media to be downloaded locally. Overriding these rules is supported, although currently unavailable via CLI. ================================================ FILE: docs/mermaid-diagrams.qd ================================================ .docname {Mermaid diagrams} .include {docs} Quarkdown offers full Mermaid interoperability via the **`.mermaid`** block function, bringing Mermaid diagrams and charts into your documents. The block parameter accepts the Mermaid code content. Refer to [Mermaid's documentation](https://mermaid.js.org/intro/) for information about its powerful syntax to create flowcharts, pie charts, class and sequence diagrams, and much more. .examplemirror .mermaid flowchart TD A([Start]) --> B[Enter username and password] B --> C{Correct?} C -- Yes --> D[Redirect to dashboard] C -- No --> E[Show error message] D --> F([End]) E --> F The Mermaid code accepts Quarkdown function calls. .examplemirror .var {n1} {2} .var {n2} {3} .mermaid flowchart TD A([Start]) --> B{.n1 + .n2 = ?} B -- .sum {.n1} {.n2} --> C([Correct]) ## Diagram from file Since function calls can be used inside the block argument, you can leverage use the [**`.read`**](file-data.qd) function to load text from a file. ```markdown .mermaid .read {chart.mmd} ``` ## Diagram caption and numbering An optional `caption` argument assigns a caption to the diagram and lets the block be numbered according to the document's *figure* [numbering](numbering.qd). .exampleoutput {![Diagram with caption](mermaid-diagrams/with-caption.png)} .mermaid caption:{My Mermaid diagram.} flowchart TD A([Start]) --> B[Enter username and password] B --> C{Correct?} C -- Yes --> D[Redirect to dashboard] C -- No --> E[Show error message] D --> F([End]) E --> F .exampleoutput {![Diagram with empty caption](mermaid-diagrams/numbered.png)} prelude:{To number the diagram without a caption, pass an empty string as the caption value.} .mermaid caption:{} flowchart TD A([Start]) --> B[Enter username and password] B --> C{Correct?} C -- Yes --> D[Redirect to dashboard] C -- No --> E[Show error message] D --> F([End]) E --> F ================================================ FILE: docs/multi-column-layout/source.qd ================================================ .doctype {paged} .pageformat columns:{2} .nonumbering # Robinson Crusoe I was born in the year 1632, in the city of York, of a good family, though not of that country, my father being a foreigner of Bremen, who settled first at Hull. He got a good estate by merchandise, and leaving off his trade lived afterward at York, from whence he had married my mother, whose relations were named Robinson, a good family in that country, and from whom I was called Robinson Kreutznear; but by the usual corruption of words in England we are now called, nay, we call ourselves, and write our name, Crusoe, and so my companions always called me. .fullspan !(50%)[](https://upload.wikimedia.org/wikipedia/commons/1/1e/Robinson_Crusoe_1719_1st_edition.jpg) I had two elder brothers, one of which was lieutenant-colonel to an English regiment of foot in Flanders, formerly commanded by the famous Colonel Lockhart, and was killed at the battle near Dunkirk against the Spaniards; what became of my second brother I never knew, any more than my father and mother did know what was become of me. Being the third son of the family, and not bred to any trade, my head began to be filled very early with rambling thoughts. My father, who was very ancient, had given me a competent share of learning, as far as house-education and a country free school generally goes, and designed me for the law, but I would be satisfied with nothing but going to sea; and my inclination to this led me so strongly against the will, nay, the commands, of my father, and against all the entreaties and persuasions of my mother and other friends, that there seemed to be something fatal in that propension of nature tending directly to the life of misery which was to befall me. My father, a wise and grave man, gave me serious and excellent counsel against what he foresaw was my design. He called me one morning into his chamber, where he was confined by the gout, and expostulated very warmly with me upon this subject. He asked me what reasons more than a mere wandering inclination I had for leaving my father's house and my native country, where I might be well introduced, and had a prospect of raising my fortunes by application and industry, with a life of ease and pleasure. He told me it was for men of desperate fortunes on one hand, or of aspiring, superior fortunes on the other, who went abroad upon adventures, to rise by enterprise, and make themselves famous in undertakings of a nature out of the common road; that these things were all either too far above me, or too far below me; that mine was the middle state, or what might be called the upper station of low life, which he had found by long experience was the best state in the world, the most suited to human happiness, not exposed to the miseries and hardships, the labor and sufferings, of the mechanic part of mankind, and not embarrassed with the pride, luxury, ambition, and envy of the upper part of mankind. He told me I might judge of the happiness of this state by one thing, viz., that this was the state of life which all other people envied; that kings have frequently lamented the miserable consequences of being born to great things, and wished they had been placed in the middle of the two extremes, between the mean and the great; that the wise man gave his testimony to this as the just standard of true felicity, when he prayed to have neither poverty nor riches. He bid me observe it, and I should always find that the calamities of life were shared among the upper and lower part of mankind; but that the middle station had the fewest disasters and was not exposed to so many vicissitudes as the higher or lower part of mankind. Nay, they were not subjected to so many distempers and uneasiness either of body or mind as those were who, by vicious living, luxury, and extravagancies on one hand, or by hard labor, want of necessaries, and mean or insufficient diet on the other hand, bring distempers upon themselves by the natural consequences of their way of living; that the middle station of life was calculated for all kind of virtues and all kind of enjoyments; that peace and plenty were the handmaids of a middle fortune; that temperance, moderation, quietness, health, society, all agreeable diversions, and all desirable pleasures, were the blessings attending the middle station of life; that this way men went silently and smoothly through the world, and comfortably out of it, not embarrassed with the labors of the hands or of the head, not sold to the life of slavery for daily bread, or harassed with perplexed circumstances, which rob the soul of peace, and the body of rest; not enraged with the passion of envy, or secret burning lust of ambition for great things; but in easy circumstances sliding gently through the world, and sensibly tasting the sweets of living, without the bitter, feeling that they are happy, and learning by every day's experience to know it more sensibly. ================================================ FILE: docs/multi-column-layout.qd ================================================ .docname {Multi-column layout} .include {docs} [**`.pageformat {columns}`**](page-format.qd) applies a multi-column layout to each page when the value of `columns` is higher than 1. .exampleoutput {![Example](multi-column-layout/two-columns.png)} .pageformat columns:{2} ## Full-span content In a multi-column layout, all elements except for level 1-3 headings render within their own column. You can set some content to span across all columns of the layout by using the **`.fullspan`** block function. .exampleoutput {![Full-span example](multi-column-layout/fullspan.png)} .fullspan ![Image](robinson-crusoe.jpg) ================================================ FILE: docs/none.qd ================================================ .docname {None} .include {docs} *None* is a special value that represents nothing or emptiness (similar to `null` in many programming languages). Functions can return it, and it also serves as a placeholder for [optional parameters](declaring-functions.qd#optional-parameters). ## Operations | Function | Description | Return type | |---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| | `.none` | Creates an empty value. | `none` | | `.isnone {value}` | Checks whether `value` is `none`. | [`Boolean`](boolean.qd) | | `.otherwise {value} {fallback}` | Returns `value` if it is not `none`, `fallback` otherwise. Works best with [function call chaining](syntax-of-a-function-call.qd#chaining-calls). | Type of either `value` or `fallback` | | `.ifpresent {value} {lambda}` | If `value` is not `none`, maps it to a new value according to the [lambda](lambda.qd). If `none`, returns `none`. Works best with function call chaining. | Type returned by `lambda`, or `none` | | `.takeif {value} {lambda}` | Returns `value` if the boolean-returning [lambda](lambda.qd) is accepted on `value`. Returns `none` otherwise. Works best with function call chaining. | Type of `value`, or `none` | ## Passing None to functions Native functions from the stdlib, written in Kotlin, often accept nullable parameters. When such a parameter is passed `None`, the function treats it as if it were `null`, which often means that the parameter is considered absent. This is particularly useful when a value is stored in a variable that might or might not be `None`, and you want to forward it to a function without checking first. .examplemirror .function {highlight} color?: .container background:{.color} Value of color: .color 1. .highlight {teal} 2. .highlight ## Example operations .example ```markdown Hi! I'm .name::otherwise {unnamed} ``` .quarkdownoutput - If `name` is `John`: *Hi! I'm John* - If it is `none`: *Hi! I'm unnamed* .example ```markdown .num::takeif {@lambda x: .x::equals {5}} ``` .quarkdownoutput - If `num` is 5: *5* - Otherwise: *None* > Confused about `@lambda`? It begins a parametric [inline `Lambda`](lambda.qd#inline-lambda). Check its page for further details. .example ```markdown .num::takeif {@lambda x: .x::iseven}::ifpresent {Even}::otherwise {Odd} ``` .quarkdownoutput - If `num` is even: *Even* - Otherwise: *Odd* .example ```markdown .x::ifpresent {@lambda Yes, .1 is present}::otherwise {Not present} ``` .quarkdownoutput - If `x` is `something`: *Yes, something is present* - If it is `none`: *Not present* > Here, the lambda parameter is implicit and accessed by position. ================================================ FILE: docs/numbering.qd ================================================ .docname {Numbering} .include {docs} The **`.numbering`** function sets the global numbering configuration of the document. The following elements can be numbered: - Headings and [table of contents](table-of-contents.qd) entries - [Figures](figure.qd) - Tables - [Equations](tex-formulae.qd) - Code blocks - [Footnotes](footnotes.qd) - Custom elements (`.numbered`) The configuration is represented by a [Dictionary](dictionary.qd). The following snippet shows the full configuration schema, where all entries are optional: ```yaml .numbering - headings: - figures: - tables: - equations: - code: - footnotes: ``` Each format parameter accepts either `none` or a string where each character represents either a counter or a fixed symbol: - `1` for decimal (`1, 2, 3, ...`) - `a` for lowercase latin alphabet (`a, b, c, ...`) - `A` for uppercase latin alphabet (`A, B, C, ...`) - `i` for lowercase roman numerals (`i, ii, iii, ...`) - `I` for uppercase roman numerals (`I, II, III, ...`) - A backslash (`\`) escapes the next character, treating it as a fixed symbol. For example, `\1` produces a literal `1` instead of a decimal counter. - Any other character is a fixed symbol. ## Default formats The default numbering format, if unspecified, is: - For `paged` documents: - `1.1.1` for headings - `1.1` for figures and tables - `(1)` for equations - `1` for footnotes - For `plain` documents: - `(1)` for equations - `1` for footnotes - For `slides` and `docs` documents: - `1` for footnotes You can turn off any active numbering configuration via the **`.nonumbering`** function. ## Merging configurations By default, `.numbering` enhances the current numbering configuration by merging the new configuration with the existing one. This means only the specified entries are updated while the rest remain unchanged. To avoid merging and turn off numbering rules for unspecified entries, set the `merge:{no}` argument: ```yaml .numbering merge:{no} - figures: 1.1 ``` ## Headings .example ```markdown .numbering - headings: 1.A.a ## Title ### Title #### Title #### Title ##### Title ### Title ## Title ### Title ``` .quarkdownoutput !(550)[Latex theme numbering](numbering/headings-latex.png) !(550)[Latex theme table of contents](numbering/toc-latex.png) ### Excluding headings from numbering To prevent a heading from being numbered, you can either: - Use a [decorative heading](headings.qd#decorative-headings) (`#!`, `##!`, etc.), which also excludes the heading from the [table of contents](table-of-contents.qd). - Use the [`.heading`](headings.qd) function with `numbered:{no}` for more granular control, allowing you to independently decide whether the heading appears in the table of contents. ## Figures [Figures](figure.qd) are numbered only if they have a **caption**, which may also be empty. .exampleoutput {!(600)[Figure numbering with format 1.1](numbering/figures-nested-format.png)} ```markdown .numbering - headings: 1.A.a - figures: 1.1 ## Title ![Logo](quarkdown-icon.svg "The Quarkdown icon.") ### Title ![Logo](quarkdown-icon.svg "") ## Title ![Logo](quarkdown-icon.svg "") ``` ## Tables .exampleoutput {!(600)[Table numbering](numbering/tables.png)} .numbering - headings: 1.A.a - tables: 1.1 ## Title | | Age | Favorite food | |-----------|-----|---------------| | **Anne** | 24 | Hamburger | | **Lucas** | 19 | Pizza | | **Joe** | 32 | Sushi | "Study results." ### Title | | Age | Favorite food | |-----------|-----|---------------| | **Anne** | 24 | Hamburger | | **Lucas** | 19 | Pizza | | **Joe** | 32 | Sushi | ## Title | | Age | Favorite food | |-----------|-----|---------------| | **Anne** | 24 | Hamburger | | **Lucas** | 19 | Pizza | | **Joe** | 32 | Sushi | ## Equations [Math blocks](tex-formulae.qd) (equations) are numbered only if they have a [cross-reference ID](cross-references.qd). .exampleoutput {!(600)[Equation numbering](numbering/equations.png)} .numbering - equations: (1) $ E = mc^2 $ {#energy} $ F = ma $ {#force} Conventionally, if the equation is not cross-referenced anywhere in the document, but you still want it to be numbered, you can use `_` as the ID. .example ```markdown $ E = mc^2 $ {#_} $ F = ma $ {#_} ``` ## Code blocks .exampleoutput {!(600)[Code blocks](numbering/equations.png)} .numbering - code: 1 ```python def hello(): print("Hello, world!") ``` ```kotlin fun main() { println("Hello, world!") } ``` ## Footnotes The numbering format of [footnotes](footnotes.qd) is flat, meaning it only considers the leftmost symbol and ignores the rest. If not specified, footnotes format defaults to `1` (decimal). .exampleoutput {![Footnote numbering](numbering/footnotes-roman.png)} .numbering - footnotes: i Here is a footnote reference[^: First], and another one[^: Second]. ## Custom numbered elements Along with the built-in numerable elements discussed above, Quarkdown allows any element to be numbered if wrapped in a `.numbered` block. The function accepts two arguments: 1. A key string. The element's number is counted across previous occurrences with the same key. 2. A [lambda](lambda.qd) block that takes the number as an argument, formatted according to the active numbering format. ```markdown .numbered {greetings} number: **Hello!** This block has the number **.number** ``` Executing the previous block renders an empty string in place of `number` because you need to specify the numbering format for `greetings` in the `.numbering` call: ```yaml .numbering ... - greetings: 1.a ``` A numbered block can also be cross-referenced. See [*Cross-references*](cross-references.qd#custom-numbered-elements) for details. Full example: .exampleoutput {!(600)[Custom numbering](numbering/custom-numbered.png)} .numbering - headings: 1.1 - greetings: 1.a ## Title 1 .numbered {greetings} number: **Hello!** This block has the number **.number** .numbered {greetings} number: **Hey!** This has instead the number **.number** ## Title 2 .numbered {greetings} number: **Hi!** Here we have the number **.number** ## Localization The localized name of the labeled element appears in captions if [`.doclang`](document-metadata.qd) is set and the locale is supported. For instance, *Figure* and *Table* for the English locale, *Figura* and *Tabella* for Italian. ================================================ FILE: docs/page-break.qd ================================================ .docname {Page break} .include {docs} A page break is a forced interruption of the page flow that causes content following the break to appear on the next page. Page breaks do not affect `plain` and `docs` documents, except for printing. > Note: The word *page* is interchangeable with *slide* in the context of a `slides` document. ## Manual break You can trigger a page break in two ways: - Placing a line containing only a sequence of 3 (or more) `<` characters: ```markdown Page 1 <<< Page 2 ``` - Using the **`.pagebreak`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Primitives/pagebreak.html} function: ```markdown Page 1 .pagebreak Page 2 ``` > The `<<<` pattern does not interrupt the current block, so it might appear as plain text if the criteria for closing a block (such as an empty line for paragraphs) are not satisfied. The following example does not trigger a page break. .examplemirror type:{warning} Page 1 <<< Page 2 ## Automatic break A heading block (`# This!`) can automatically trigger a page break. By default, only level-1 headings (one `#` symbol) trigger automatic page breaks, but you can customize this via the **`.autopagebreak`** function. Call this function in the setup area of your source code (along with metadata, page format, etc.). This function takes a `maxdepth` integer argument that indicates the maximum level a heading should be in order to trigger a page break. Heading levels range from 1 to 6. .example ```html .autopagebreak maxdepth:{3} ## A ### B #### C ##### D ``` ### Disabling automatic breaks **`.noautopagebreak`**, which is equivalent to `.autopagebreak {0}`, disables automatic breaks. ================================================ FILE: docs/page-counter.qd ================================================ .docname {Page counter} .include {docs} The **`.currentpage`** and **`.totalpages`** functions display, respectively, the current index (beginning from 1) of the page or slide where the function call appears and the total number of pages or slides. They do not accept any arguments. These functions are supported in `paged` and `slides` documents. In other document types, the `-` placeholder is shown instead. > Note: These functions return visual elements (nodes), *not* numbers. Therefore, you cannot perform operations like `.sum {.currentpage} {3}`. ## Fixed page counter You can display a page counter on each page using [page margin content](page-margin-content.qd): ```markdown .pagemargin {bottomcenter} .currentpage / .totalpages ``` ## Formatting the page number The **`.formatpagenumber {format}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/formatpagenumber.html} function sets a new page number format from the page where it appears. It only affects page numbers from that point onwards. The format string accepts the same syntax as the one in [numbering](numbering.qd): - `1` (default) for decimal (1, 2, 3, ...) - `a` for lowercase latin alphabet (a, b, c, ...) - `A` for uppercase latin alphabet (A, B, C, ...) - `i` for lowercase roman numerals (i, ii, iii, ...) - `I` for uppercase roman numerals (I, II, III, ...) These changes are reflected in `.currentpage` and page numbers in the [table of contents](table-of-contents.qd). .exampleoutput {![Page number format](page-counter/format.png)} .pagemargin {topcenter} .currentpage # First page .formatpagenumber {i} # Second page # Third page ## Resetting the page number The **`.resetpagenumber {from?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/resetpagenumber.html} function allows you to overwrite the current page number at any point in a `paged` or `slides` document. These changes are reflected in `.currentpage` and page numbers in the [table of contents](table-of-contents.qd). .exampleoutput {![Page number reset](page-counter/reset.png)} .pagemargin {topcenter} .currentpage # First page # Second page .resetpagenumber start:{20} # Third page ================================================ FILE: docs/page-format.qd ================================================ .docname {Page format} .include {docs} The **`.pageformat`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/pageformat.html} function configures the page format. All its parameters are **optional**, and if left unset, they delegate their default value to the underlying renderer depending on the document type. Multiple calls to `.pageformat` are layered on top of each other, with later calls overriding earlier ones. | Parameter | Description | Accepts | Supported documents | |----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|------------------------------------| | `side` | Restricts the format to left (verso) or right (recto) pages only. See [Scoped formatting](#scoped-formatting). | `left`, `right` | `paged` | | `pages` | Restricts the format to specific pages by 1-based inclusive range. See [Scoped formatting](#scoped-formatting). Combinable with `side`. | Range, e.g. `2..5` | `paged` | | `size` | Name of the paper format. | `A0`..`A10`, `B0`..`B5`, `letter`, `legal`, `ledger` | `paged`, `slides` | | `width` | Page width. If `size` is set too, this value overrides its width. | [`Size`](sizes.qd#size-single-size), e.g. `300px`, `15cm`, `5.8in` | `plain`, `paged`, `slides`, `docs` | | `height` | Page height. If `size` is set too, this value overrides its height. | [`Size`](sizes.qd#size-single-size), e.g. `300px`, `15cm`, `5.8in` | `paged`, `slides` | | `orientation` | Whether width and height of the paper format (`size`) should be swapped. This defaults to `portrait` for `plain` and `paged` documents and to `landscape` for `slides`. | `portrait`, `landscape` | `paged`, `slides` | | `margin` | Blank space between page borders and content area. | [`Sizes`](sizes.qd#size-group-sizes), e.g. `1cm`, `15mm 30px`, `2in 1in 3in 2in` | `plain`, `paged`, `slides` | | `bordertop`, `borderright`, `borderbottom`, `borderleft` | Thickness of the border at each side of the content area. | [`Size`](sizes.qd) | `plain`, `paged`, `slides` | | `bordercolor` | Color of the border around the content area. | [`Color`](color.qd) | `plain`, `paged`, `slides` | | `columns` | Number of columns in each page. If set to 2 or higher, the document has a [multi-column layout](multi-column-layout.qd). | Positive integer | `plain`, `paged`, `slides` | | `alignment` | Horizontal content and text alignment. | `start` (default in `slides`), `center`, `end`, `justify` (default in `plain` and `paged`) | `plain`, `paged`, `slides`, `docs` | ## Content area Each page consists of a *content area* in which the main content is displayed, and a *margin area*, a blank outline that may host [page margin content](page-margin-content.qd) such as [page counters](page-counter.qd). !(400)[Content area](page-format/content-area.png) ### Margins The `margin` parameter affects the size of the margin area, reducing the surface of the content area. .exampleoutput {!(500)[Margins](page-format/margins.png)} .pageformat margin:{4cm} ### Borders The `bordertop`, `borderright`, `borderbottom`, `borderleft`, and `bordercolor` parameters allow customization of borders around the content area of each page in `paged` and `slides` documents. - If you specify at least one side, the border applies to the specified sides. If you do not specify the color, it uses the default foreground text color. - If you do not specify any side but do specify the color, the border applies to all sides with a default thickness. .exampleoutput {!(500)[image](page-format/borders.png)} .pageformat bordertop:{1px} borderbottom:{4px} ## Scoped formatting In `paged` documents, the `side` and `pages` parameters restrict a format to specific pages. ### Per-side formatting The `side` parameter restricts a format to left (verso) or right (recto) pages only. This is useful, for example, for mirrored margins in book-style layouts. .exampleoutput {![Per-side formatting](page-format/sides.png)} .pageformat size:{A4} .pageformat side:{left} margin:{2cm 3cm 2cm 1cm} .pageformat side:{right} margin:{2cm 1cm 2cm 3cm} ### Per-range formatting The `pages` parameter restricts a format to an inclusive range of page indices, starting from 1. This is useful, for example, for applying distinct styles to the first few pages of a document. ``` .pageformat pages:{2..5} margin:{3cm} ``` `side` and `pages` can also be combined to target specific sides within a range: ``` .pageformat side:{left} pages:{1..3} bordercolor:{green} ``` ================================================ FILE: docs/page-margin-content.qd ================================================ .docname {Page margin content} .include {docs} The **`.pagemargin`** function displays content on each page in a fixed position along its [margins](page-format.qd#content-area). - In `paged` documents, a special area of each page is reserved for margins: !(_*600)[Paged margin areas](page-margin-content/margin-areas.png) > Credits: [Paged.js](https://pagedjs.org/documentation/7-generated-content-in-margin-boxes/#margin-boxes-of-a-page) - In `plain` and `slides` documents, content set on margins could potentially overlap page content. - In `plain` documents, where the concept of *page* does not exist, page margins are displayed once per document. The function accepts an optional `position` and a body argument `content`: | Parameter | Description | Accepts | |------------|----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `position` | Page area to target. | `topleftcorner`, `topleft`, `topcenter`, `topright`, `toprightcorner`, `righttop`, `rightmiddle`, `rightbottom`, `bottomrightcorner`, `bottomright`, `bottomcenter`, `bottomleft`, `bottomleftcorner`, `leftbottom`, `leftmiddle`, `lefttop`, *`topoutsidecorner`, `topoutside`, `topinsidecorner`, `topinside`, `bottomoutsidecorner`, `bottomoutside`, `bottominsidecorner`, `bottominside`* | | `content` | Element to display. | [Block content](markdown-content.qd#block-content) | .exampleoutput {![Fixed positions](page-margin-content/fixed-positions.png)} .pagemargin {topright} **This** is a margin content. ## Scoped margins When used in `paged` and `slides` documents, page margin content takes effect only from the page where it is declared onward. This allows for different page margin settings in different parts of the document. .exampleoutput {![Scoped page margins](page-margin-content/scoped-margins.png)} .pagemargin {topcenter} On all pages ## First page ## Second page .pagemargin {topleft} From second page ## Third page Overwriting the page margin again changes it from that point onward. .exampleoutput {![Scoped page margins](page-margin-content/scoped-overwrite.png)} ... ## Third page .pagemargin {topcenter} From third page ## Mirror positions Along with fixed positions such as `topright` or `bottomleft`, Quarkdown also supports *mirror positions*, which adapt based on whether the page is left (even number) or right (odd number). Mirror positions are marked in italics in the table above and refer to `outside` and `inside` areas: .exampleoutput {![Mirror positions](page-margin-content/mirror-positions.png)} .pagemargin {topoutside} **This** is a margin content. ## Footer Most layout themes associate the `bottomcenter` margin with the document footer and style it differently. For instance, different blocks may be displayed in a row. Footers are particularly common in `slides` documents. The **`.footer`** function is a shorthand for `.pagemargin {bottomcenter}`. .exampleoutput {![Page footer](page-margin-content/footer.png)} .theme {beaver} layout:{beamer} .footer .docauthor **.docname** [GitHub](https://github.com/iamgio/quarkdown) ## Page counter A page margin can host a page counter. See [Page counter](page-counter.qd) for more information. ================================================ FILE: docs/paper-library.qd ================================================ .docname {Paper library} .include {docs} .include {paper} The built-in .repolink {`paper`} {blob/main/quarkdown-libs/src/main/resources/paper} library is written in Quarkdown and adds support for typical elements of scientific papers in a LaTeX fashion. The library features the following components: - Abstract - Titled, numbered blocks: - Definitions - Lemmas - Theorems - Proofs > Note: The supported languages align with those supported by Quarkdown's core. See [*Built-in localization*](localization.qd#built-in-localization) for further information. The first step is to [import](importing-external-libraries.qd) the library: ```markdown .include {paper} ``` ## Abstract **`.abstract`** generates the layout for a titled *abstract* block. Its content goes in the block argument. .exampleoutput {![Abstract](paper-library/abstract.png)} .abstract This is my *abstract*! Here goes the summary of the document. .loremipsum This is not part of the abstract, instead. The alignment of the title defaults to center and can be changed via `.abstractalignment {start|center|end}`. .exampleoutput {![Abstract with start-aligned title](paper-library/abstract-start-aligned.png)} .abstractalignment {start} .abstract This is my *abstract*! Here goes the summary of the document. .loremipsum ## Titled blocks You can create any of the following blocks: - Definition via **`.definition`** - Lemma via **`.lemma`** - Theorem via **`.theorem`** - Proof via **`.proof`** All the mentioned functions take one block argument that defines the content. .exampleoutput {![Definition](paper-library/definition.png)} .definition Let $ \Delta x $ be an object's change in position over a time interval $ \Delta t $, then the average velocity is defined as $ v = \frac {\Delta x} {\Delta t} $. ### Custom title suffix The default title suffix is `.` (dot) and can be customized via `.paperblocksuffix {suffix}`: .exampleoutput {![Custom block suffix](paper-library/custom-suffix.png)} .paperblocksuffix {:} ### Numbering Defining a [numbering format](numbering.qd) causes the blocks of that type to be numbered. The format names are plural: `definitions`, `lemmas`, `theorems`, `proofs`. .exampleoutput {!(600)[Numbered blocks](paper-library/numbered-blocks.png)} .numbering - definitions: 1.a - lemmas: i ... .definition .loremipsum .lemma .loremipsum .definition .loremipsum ### End-of-proof Proofs also feature a special *end-of-proof* character, which defaults to `∎`. .exampleoutput {![Proof with end character](paper-library/proof.png)} .theorem .loremipsum .proof .loremipsum You can customize the end-of-proof character via `.proofend {string}`: .exampleoutput {![Proof character customization](paper-library/proof-custom-end.png)} .proofend {😎} ================================================ FILE: docs/paragraph-style.qd ================================================ .docname {Paragraph style} .include {docs} The **`.paragraphstyle`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/paragraphstyle.html} function allows you to override the global style of paragraphs. All parameters are optional, and if left unset, they delegate their value to the active theme. | Parameter | Description | Accepts | |-----------------|-----------------------------------------------------------------------------------|---------| | `lineheight` | Whitespace between lines, multiplied by the font size. | Number | | `letterspacing` | Whitespace between letters, multiplied by the font size. | Number | | `spacing` | Whitespace between subsequent paragraphs, multiplied by the font size. | Number | | `indent` | Whitespace at the start of each non-first paragraph, multiplied by the font size. | Number | Using `spacing:{0} indent:{2}` produces the classic LaTeX look. .exampleoutput {![Customized](paragraph-style/customized.png)} .paragraphstyle lineheight:{2.5} spacing:{0} indent:{2} ================================================ FILE: docs/pdf-export.qd ================================================ .docname {PDF export} .include {docs} When running Quarkdown's [compiler](cli-compiler.qd) via `quarkdown c`, specifying the **`--pdf`** flag generates a PDF file. - The content of the PDF matches exactly what the HTML output would render in the Chrome browser. - All document types and features supported by the HTML target are also supported. ### Requirements To generate PDF files from HTML, the following dependencies are required: - Node.js - npm (usually bundled with Node.js) - [Puppeteer](https://pptr.dev) (`npm install puppeteer --prefix /lib`) Package managers and install scripts already take care of these dependencies. ### Additional options - `--node-path `: Sets the path to the Node.js executable. Defaults to `node`. - `--npm-path `: Sets the path to the npm executable. Defaults to `npm`. - `--pdf-no-sandbox`: Disables Chrome sandbox during PDF generation. This is potentially unsafe and should only be used when strictly needed. For instance, some Linux distributions do not support headless sandbox. ### Environment variables - `QD_NPM_PREFIX`: Directory where `node_modules` should be found. Defaults to `lib` if Quarkdown was installed via a package manager or install script. ### Exporting manually (legacy way) You can export an HTML artifact to PDF via the **in-browser print** feature (`CTRL/CMD + P`). While `paged` (and `plain`) documents are print-ready, you need a few additional steps to save your `slides` document as PDF. Please refer to [Reveal's instructions](https://revealjs.com/pdf-export/#instructions) to learn how to do so. ================================================ FILE: docs/persistent-headings.qd ================================================ .docname {Persistent headings} .include {docs} The **`.lastheading {depth}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/lastheading.html} function allows you to reference the last heading of a given depth across pages or slides. When used in combination with [page margin content](page-margin-content.qd), this function enables persistent headings, such as chapter titles or section names, to appear in the page margins. > Note: `depth` refers to the heading level: `1` for `#`, `2` for `##`, and so on. .exampleoutput {![Persistent heading](persistent-headings/result.png)} .pagemargin {topcenter} *.lastheading depth:{1}* ## Chapter 1 .repeat {10} .loremipsum Note that headings of lesser depth reset the last reference. .exampleoutput {![Reset](persistent-headings/reset.png)} prelude:{In the following example, the depth-2 persistent heading appears when on a depth-2 section (page 2), but resets when entering a depth-1 section (page 3).} .pagemargin {topleft} *.lastheading depth:{1}* .pagemargin {topright} *.lastheading depth:{2}* ## Chapter 1 .repeat {6} .loremipsum ### Subsection .loremipsum ## Chapter 2 .repeat {2} .loremipsum ================================================ FILE: docs/pipeline---function-call-expansion.qd ================================================ .docname {Pipeline - Function call expansion} .include {docs} > Main packages: .repolink {`core.function`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/function} Among the nodes generated from the [Parsing](pipeline---parsing.qd) stage, those of type `FunctionCallNode(context, name, arguments)` represent [function calls](syntax-of-a-function-call.qd). While all other nodes never change after they are created, this is the only kind of *mutable* node, which means its inner data is expected to change after the AST has been fully generated. Its mutation affects its child nodes, which is initially an empty collection that is later populated by a component called *function call expander*. Before addressing the expansion itself, we should understand how data is exchanged among functions. Quarkdown functions, either explicitly or implicitly, always return a [`Value`](https://github.com/iamgio/quarkdown/tree/main/quarkdown-core/src/main/kotlin/eu/iamgio/quarkdown/function/value), a type-checked object wrapper. In Quarkdown, not all types can be returned and not all types can be used as arguments. Therefore, functions should feature `InputValue` parameters and return an `OutputValue`. A complete set of value types is visualized in the following Venn-UML diagram: !(80%)[Value types](pipeline---function-call-expansion/value-types.svg) Whenever a function returns some `OutputValue`, it must be converted to some `Node` that can be rendered on screen. For instance, a `StringValue` becomes text, an `OrderedCollectionValue` becomes an ordered list, a `BooleanValue` becomes a checkbox, a `DictionaryValue` becomes a table, and so on. This operation is handled by a *value-node mapper*. For each function call node enqueued by the parser, the corresponding function is looked up among loaded libraries[^1], argument-parameter bindings are established[^2], and the function is executed to obtain its output. After retrieving the output node from the mapper, the expander can push it to the function call's child nodes, ready for the next stage. [^1]: User-defined functions are also dynamically stored into a volatile library. Native libraries (e.g. `stdlib`) load their functions via reflection instead. [^2]: Quarkdown is [dynamically typed](typing.qd), while the native libraries are written in a statically typed language (Kotlin). When an argument-parameter binding is created, if the argument's type is dynamic, a conversion to its parameter's static type is performed via the `ValueFactory`. If the conversion fails, an error is thrown. ================================================ FILE: docs/pipeline---lexing.qd ================================================ .docname {Pipeline - Lexing} .include {docs} > Main packages: .repolink {`core.lexer`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer} Lexing is like breaking down a sentence into its individual words before figuring out the meaning of the sentence. Imagine you are reading a paragraph, and before understanding the message, you first recognize individual words like nouns, verbs, and punctuation. In Quarkdown, the lexing process scans a source file, which is nothing but a sequence of characters, and splits it into small pieces called **tokens**. Each token represents a different element, like a heading, a paragraph, or bold text, and stores basic information such as its type, its position in the text, and its textual content (*lexeme*). Markdown recognizes two macro-categories of tokens: **block** tokens and **inline** tokens. The difference is based on how these elements are structured in the document: - Blocks are sections that define the outer structure of a document. For example, a paragraph, a list, a heading, a code block, or a quote. ```markdown # A heading A paragraph > A quote - A list - of multiple items ``` !(500)[Blocks](pipeline---lexing/blocks-diagram.svg) - Inlines are elements that appear inside blocks and define, most commonly, textual features such as formatting. For example, bold, italics, monospaced, links, and images. ```markdown A **formatted** _text_. ``` To accomplish this separation, two distinct lexers are supplied: a block lexer and an inline lexer, which extract their corresponding tokens. **Function calls** are extracted both as blocks and inlines, with just a [few differences](syntax-of-a-function-call.qd#block-vs-inline-function-calls) between them. At the beginning, only the block lexer is invoked. Once the source is broken down into its outer blocks, they are passed to the parser, which is delegated to search for nested information. ================================================ FILE: docs/pipeline---parsing.qd ================================================ .docname {Pipeline - Parsing} .include {docs} > Main packages: .repolink {`core.parser`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/parser}, .repolink {`core.ast`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/ast} Continuing with the metaphor introduced in [Lexing](pipeline---lexing.qd), once the nouns, verbs, and adjectives are extracted from a sentence, our brain is responsible for linking them together to build information out of them. The parser takes the sequence of tokens and organizes them into a tree structure called an *Abstract Syntax Tree* (AST), which defines the relationships between different parts of the document. Each element of the tree is called a *Node*. --- Example Markdown input: ```markdown ## Title This is **bold** and _italic_ text. - Item 1 - Item 2 ``` Output AST: - `AstRoot` - `Heading(depth=1)` - `Text("Title")` - `Paragraph` - `Text("This is ")` - `Strong("bold")` - `Text(" and ")` - `Emphasis("italic")` - `Text(" text")` - `UnorderedList` - `ListItem` - `Paragraph` - `Text("Item 1")` - `ListItem` - `Paragraph` - `Text("Item 2")` --- The *lexing* stage produces just the outer blocks, which in this example are a `HeadingToken`, a `ParagraphToken`, and an `UnorderedListToken`. To gain nested information, the parser analyzes each token and starts searching in depth for nested blocks and inlines. - For each block token, the parser triggers the lexing stage on its inner content (*lexeme*) - Once the inner tokens are extracted, they undergo the parsing stage again - This process continues until no more nested tokens remain. This is called **recursive parsing**, visualized in the following figure: !(200)[Recursive parsing](pipeline---parsing/recursive-parsing.svg) ================================================ FILE: docs/pipeline---post-rendering.qd ================================================ .docname {Pipeline - Post rendering} .include {docs} > Main packages: .repolink {`core.rendering`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering}, .repolink {`core.pipeline.output`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output} > > Rendering modules: .repolink {`quarkdown-html`} {tree/main/quarkdown-html}, .repolink {`quarkdown-plaintext`} {tree/main/quarkdown-plaintext} After obtaining the translation of the AST to the target format from the [Renderer](pipeline---rendering.qd), you might notice that it is not enough to display to the user. Considering the HTML format, that is just the content that would go inside ``, but everything else is missing: metadata, styling, and possibly a runtime. Here comes the *post-renderer*, which programmatically builds the full document around the rendered content. For HTML, this is handled by `HtmlDocumentBuilder` (using the `kotlinx.html` DSL), located in .repolink {`html.post.document`} {tree/main/quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/document}. The builder injects all the needed data, such as: - The content itself, placed into `` - The document target (plain/slides/paged/docs). Each target requires different scripts, stylesheets, and body structure - User-defined properties, such as title, page format, and fonts - The need to load certain libraries, such as KaTeX for rendering LaTeX formulas. This is only done if at least one formula is used On top of that, the post-renderer is also responsible for returning the output resources of the compilation. These resources include: - The generated HTML - The group of stylesheets (global stylesheet, layout theme, and color theme) - The group of required runtime scripts These resources are then added to those provided by the [media storage](media-storage.qd) and ultimately returned by the pipeline. It is then up to the invoker to handle those resources, which in the case of .repolink {CLI} {tree/main/quarkdown-cli} are saved to file. ================================================ FILE: docs/pipeline---rendering.qd ================================================ .docname {Pipeline - Rendering} .include {docs} > Main packages: .repolink {`core.rendering`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering} > > Rendering modules: .repolink {`quarkdown-html`} {tree/main/quarkdown-html}, .repolink {`quarkdown-plaintext`} {tree/main/quarkdown-plaintext} Once the AST is fully generated and enriched, it is time to translate it into a target format, such as HTML. This translation is performed via a depth-first traversal of the AST, starting from the root. Each visit to a node produces some output (in the case of HTML, an element tag) which, ideally in a one-to-one fashion, translates the information stored by the node into the target format. To ensure scalability, Quarkdown locates each rendering target in external modules, such as `quarkdown-html`, which can be plugged into the core architecture. --- Example Markdown input: ```markdown ## Title This is **bold** and _italic_ text. - Item 1 - Item 2 ``` Output HTML: ```html

Title

This is bold and italic text.

  • Item 1
  • Item 2
``` ================================================ FILE: docs/pipeline---tree-traversal.qd ================================================ .docname {Pipeline - Tree traversal} .include {docs} > Main packages: .repolink {`core.ast.iterator`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/iterator}, .repolink {`core.context.hooks`} {tree/main/quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks} After the function calls have been expanded, the AST is traversed depth-first to gather enriched information about the document, such as: - Heading hierarchy, used for the [table of contents](table-of-contents.qd) - [Numbering](numbering.qd): each heading, figure, and other numbered element is assigned a unique number based on its location in the heading hierarchy - Link definition bound to each link reference For performance reasons, only one traversal is performed during this stage. Each operation can however act independently by attaching its own *hook* to the tree iterator, which is triggered when nodes of a certain kind are encountered. ================================================ FILE: docs/pipeline.qd ================================================ .docname {Pipeline} .include {docs} When you supply an input file to Quarkdown, it undergoes a process of elaboration to be transformed into an output resource, such as an HTML document that the browser can display. Under the hood, this process is represented by a **sequential pipeline**, in which the output of one stage becomes the input of the next one. 1. [**Lexing**](pipeline---lexing.qd) 2. [**Parsing**](pipeline---parsing.qd) 3. [**Function call expansion**](pipeline---function-call-expansion.qd) 4. [**Tree traversal**](pipeline---tree-traversal.qd) 5. [**Rendering**](pipeline---rendering.qd) 6. [**Post-rendering**](pipeline---post-rendering.qd) > *This section aims to be a simplification of what is explained in the author's Bachelor's thesis: [Quarkdown - Typesetting versatile di documenti articolati](https://amslaurea.unibo.it/id/eprint/33690/1/garofalo_giorgio_tesi.pdf) (Italian), in which Quarkdown's architecture is thoroughly explained and documented. The thesis is updated to September 2024, while this section is going to be kept up to date.* > > !(_*500)[Dissertation](pipeline/thesis-cover.png) ================================================ FILE: docs/quickstart.qd ================================================ .docname {Quickstart} .include {docs} Welcome to Quarkdown! This guide walks you through the main features you need to create your first document. > Tip: If you use VS Code, we strongly recommend installing the [official Quarkdown extension](https://marketplace.visualstudio.com/items?itemName=quarkdown.quarkdown-vscode). This guide provides a broad overview without diving deep into every detail. Each section includes links to other wiki pages where you can learn more about specific features. ## Creating a document > Reference: [Project Creator](cli-project-creator.qd) To create a new document, run the project creator from your terminal: ```shell quarkdown create my-document ``` This command creates a new folder named `my-document`. If you want to use the current working directory instead, you can omit the name. For this quickstart, we recommend the following options: - Project name: `Quickstart` - Authors: \ - Document type: `plain` - Document language: `English` Once the project is created, open `my-document.qd` in your favorite text editor. You can delete the existing content inside the first section if you want to start fresh. ```diff ## Quickstart - Welcome to [Quarkdown](https://quarkdown.com)! - This is the starting point of your document. - ... ``` ### Choosing a document type > Reference: [Document types](document-types.qd) During the initial setup, you choose a document type from one of the following options: - **`plain`**: A continuous flow layout similar to Notion or Obsidian, ideal for knowledge management and web viewing. - **`paged`**: A traditional document layout with page breaks, perfect for PDFs. - **`slides`**: A presentation format with interactive navigation. - **`docs`**: A documentation format with a sidebar and table of contents, ideal for knowledge bases and wikis. This wiki itself is a `docs` document. ### Choosing a theme > Reference: [Themes](themes.qd) Themes are split into two groups: *color* themes define the color scheme of a document, while *layout* themes set the general structural rules of the layout. ```markdown .theme {paperwhite} layout:{latex} ``` Some popular combinations include: - `paperwhite + latex`: A clean academic look (this is the default) - `darko + minimal`: A dark theme with minimal styling - `galactic + hyperlegible`: The theme used in this wiki, with a highly readable font - `beaver + beamer`: An academic presentation style See [Themes](themes.qd) for a complete list of available themes. ## Compiling > Reference: [Compiler](cli-compiler.qd) Compile the document to make sure everything works as expected: ```shell cd my-document quarkdown c main.qd ``` You should see the HTML output inside the `output` directory. To export to PDF, add the `--pdf` flag: ```shell quarkdown c main.qd --pdf ``` See the [PDF export](pdf-export.qd) page for more information about PDF export and its requirements. If you installed Quarkdown using a package manager or the install script, you should be ready to go. ## Live preview To launch live preview, append `-p` (preview) and `-w` (watch) to the compile command: ```shell quarkdown c main.qd -p -w ``` This opens the default browser automatically. To use a different browser, specify it with the `--browser` option, for example `--browser chrome`. If you use VS Code with the Quarkdown extension, you can launch the live preview by clicking the magnifying glass button or pressing `Ctrl/Cmd+Shift+V`. ## Markup Quarkdown builds upon Markdown and includes several GFM extensions. To try out basic markup, append the following to your document and experiment with it: ```markdown ### A Quarkdown tour No need to reinvent the wheel: you can make text **bold** or *italic*, ~~strike through~~, add `inline code`, create [links](https://quarkdown.com), and much more. - An unordered list - With multiple items 1. An ordered list 2. With multiple items ![Logo](image/logo.png) ``` !(650)[Markup](quickstart/markup.png) For a refresher on Markdown basics, see [markdownguide.org](https://www.markdownguide.org/basic-syntax). ### Image size > References: [Image size](image-size.qd), [Size](sizes.qd) You might have noticed that the image in the previous example appears too large. Quarkdown overcomes this well-known Markdown limitation by allowing you to specify image dimensions: ```markdown !(50%)[Logo](image/logo.png) ``` Now the image is sized appropriately. !(650)[Image size](quickstart/image-size.png) This sets the image width to 50% of the available width while maintaining the aspect ratio. Supported units include `px`, `pt`, `cm`, `mm`, `in`, and `%`. If you omit the unit, Quarkdown assumes `px`. To specify both width and height: ```markdown !(6cm 2cm)[Logo](image/logo.png) ``` To specify only the height: ```markdown !(_ 2cm)[Logo](image/logo.png) ``` ### Figures > Reference: [Figures](figure.qd) When an image stands alone, separated from other content, it automatically becomes a figure. Figures are centered and can have a caption. Take the previous image: ```markdown !(50%)[Logo](image/logo.png) ``` You can add a caption by appending a title in quotes: ```markdown !(50%)[Logo](image/logo.png "The Quarkdown logo") ``` !(650)[Figure](quickstart/figure.png) ### Equations and formulae > Reference: [TeX formulae](tex-formulae.qd) You can include TeX equations using the `$ expression $` syntax. Note that whitespace matters. Add the following to the bottom of your document: ```markdown #### Equations $ E = mc^2 $ is Einstein's mass-energy equivalence formula. $ f(x, y) = \frac{x}{y} $ ``` Notice that the second equation appears centered on its own line, even though it uses the same syntax. Just like figures, isolated equations automatically become block equations. !(650)[Equations](quickstart/equations.png) ## Function call syntax > Reference: [Syntax of a function call](syntax-of-a-function-call.qd) Before exploring more features, let's look at Quarkdown's function call syntax. Function calls start with a dot and use curly braces for arguments. Arguments can be positional or named: ```markdown .pow {5} to:{2} is a mathematical operation. ``` Function calls can be *inline* or *block*, depending on whether they appear within other content or stand alone. Block function calls also support an indented *block argument*, which always corresponds to the last parameter of the function: ```markdown .align {center} This multi-line paragraph is the block argument, and it's now centered in the document. ``` You can chain multiple function calls together, where the output of one becomes the first argument of the next: .examplemirror .sqrt {10}::round::multiply {2} > Tip: you can find a full list of available functions in the [documentation](https://quarkdown.com/docs/quarkdown-stdlib/). ### Variables > Reference: [Variables](variables.qd) Define a variable using the `.var` function and access it like any other function: ```markdown .var {name} {John} My name is .name ``` To change a variable's value, call it with a new argument: ```markdown .name {Jane} ``` ## Custom elements > Reference: [Declaring functions](declaring-functions.qd), [Boxes](box.qd) You can define custom functions using `.function`. Functions are reusable components that encapsulate logic and content. In this section, we create a function that generates an *Example* box, commonly used in educational documents. Start by defining a function without parameters: ```markdown .docauthors - ... .function {example} .box {Example} type:{tip} This is my example! ## Quickstart ... ``` Now call it: ```markdown ## Quickstart .example ... ``` Calling `.example` produces a nicely formatted box. Let's enhance the function by adding a parameter for the box content. The syntax for parameters is `param1 param2 param3:`. ```markdown .function {example} content: .box {Example} type:{tip} .content ``` Update the call accordingly: ```markdown .example This is my example! ``` !(650)[Custom element](quickstart/custom-element.png) Congratulations! You've created your first reusable element using [boxes](box.qd). Many more layout tools are available, such as [containers](container.qd) and [stacks](stacks.qd). For advanced styling of custom elements, you can use CSS rules. See [CSS](css.qd) for more information. > For the sake of simplicity, let's remove the function call for now: > > ```diff > - .example > - This is my example! > ``` ## Numbering > Reference: [Numbering](numbering.qd) Many documents, especially academic ones, require numbered elements such as sections, figures, tables, equations, and code. In Quarkdown, you can achieve this using the `.numbering` function. > Note: Documents with the `paged` type come with default numbering enabled. Add a numbering scheme at the top of your document, right before the content begins: ```markdown .docauthors - ... .numbering - headings: 1.1.1 - figures: 1.1 .function {example} ... ## Quickstart ... ``` > This YAML-like syntax represents a [dictionary](dictionary.qd) data type. !(650)[Numbering](quickstart/numbering.png) Valid symbols for numbering formats are: - `1` for decimal numbers - `A` for uppercase letters - `a` for lowercase letters - `I` for uppercase Roman numerals - `i` for lowercase Roman numerals Any other character is treated as literal text. ## Table of contents > Reference: [Table of contents](table-of-contents.qd) Longer documents often include a table of contents at the beginning that lists all sections and subsections. In Quarkdown, simply call the `.tableofcontents` function where you want the table of contents to appear: ```markdown .numbering - ... .tableofcontents ## Quickstart ... ``` ## Footnotes > Reference: [Footnotes](footnotes.qd) Footnotes let you add additional information without cluttering the main text. Let's add a footnote to our introduction: ```markdown ### A Quarkdown tour No need to reinvent the wheel: you can make text **bold** or *italic*, ~~strike through~~, add `inline code`, create [links](https://quarkdown.com), and much more[^1]. [^1]: All Markdown features are supported in Quarkdown ``` This is the GFM footnote syntax, which Quarkdown fully supports. Quarkdown also supports compact footnotes, which let you define a footnote inline, right where you reference it: ```markdown and much more[^: All Markdown features are supported in Quarkdown]. ``` In `plain` documents, footnotes appear in the margin next to the reference, similar to side notes. In `paged` and `slides` documents, footnotes appear at the bottom of the page or slide. ## Cross-references > Reference: [Cross-references](cross-references.qd) Cross-references let you refer to numbered elements elsewhere in your document. First, add an ID to the target element. Then reference it using the `.ref {id}` function. Take our figure: ```markdown !(50%)[Logo](image/logo.png "The Quarkdown logo") ``` Add the `logo` ID to it and reference it in the text: ```markdown !(50%)[Logo](image/logo.png "The Quarkdown logo") {#logo} The Quarkdown logo is shown in .ref {logo}. ``` !(650)[Cross-reference](quickstart/cross-reference.png) See [Cross-references](cross-references.qd) to learn how to add IDs to other elements, such as sections, tables, equations, and code blocks. ## Page margins and counter > References: [Page format](page-format.qd), [Page margin content](page-margin-content.qd), [Page counter](page-counter.qd) In `paged` and `slides` documents, you typically want a page counter to help readers navigate and understand where they are in the document. First, switch to the `paged` document type by changing the `.doctype` value at the top of your document: ```diff - .doctype {plain} + .doctype {paged} ``` > The default page format is A4. You can change it, along with other properties, using the `.pageformat` function: > > ```markdown > .pageformat {letter} margin:{2cm} > ``` Now add a page counter to the bottom center of each page: ```markdown .docauthors - ... .pagemargin {bottomcenter} .currentpage / .totalpages .numbering - ... ``` Each page now displays its number and the total number of pages. The `.pagemargin` function can add any kind of content to each page. See [Page margin content](page-margin-content.qd) for all available margin positions. ### Persistent headings > Reference: [Persistent headings](persistent-headings.qd) Page margins can also display the current section title on each page. The following example shows the current level-1 heading in the bottom left margin: ```markdown .pagemargin {bottomcenter} ... .pagemargin {bottomleft} .lastheading depth:{1} .numbering - ... ``` ## Multi-file projects > Reference: [Inclusion vs. subdocuments](inclusion-vs-subdocuments.qd) There are two main ways to split a large document into multiple source files: inclusion and subdocuments. ### Including files > Reference: [Including other Quarkdown files](including-other-quarkdown-files.qd) The `.include` function inserts the content of another Quarkdown file into the current one. This approach is common in LaTeX and other markup languages for splitting a large document into smaller, more manageable files, such as one per chapter or section. For our quickstart, we could split setup and content into two separate files: > my-document.qd ```diff - .docname {Quickstart} - .doctype {paged} - .doclang {English} - .theme {paperwhite} layout:{latex} - - .docauthors - - ... - - .pagemargin {bottomright} - .currentpage / .totalpages - - .pagemargin {bottomleft} - .lastheading depth:{1} - - .numbering - - headings: 1.1.1 - - figures: 1.1 + .include {setup.qd} + .include {content.qd} - .tableofcontents - - # Quickstart - - ... ``` > setup.qd ```markdown .docname {MyDocument} .doctype {paged} .doclang {English} .theme {paperwhite} layout:{latex} .docauthors - Quarkdown .pagemargin {bottomright} .currentpage / .totalpages .numbering - headings: 1.1.1 - figures: 1.1 ``` > content.qd ```markdown .tableofcontents ## Quickstart ... ``` Here we used `.include` twice. For a larger number of files, consider using `.includeall` instead: ```markdown .includeall - setup.qd - content.qd ``` ### Referencing files > Reference: [Subdocuments](subdocuments.qd) The second approach uses subdocuments, which are particularly common for knowledge bases and wikis. Subdocuments are independent units of content that share the same setup and configuration. You can reference them from other documents through links. > my-document.qd ```markdown - [Introduction](introduction.qd) - [Chapter 1](chapter1.qd) - [Chapter 2](chapter2.qd) - [Conclusion](conclusion.qd) ``` You can also visualize the knowledge graph formed by subdocuments: ```markdown .subdocumentgraph ``` !(650)[Subdocuments](quickstart/subdocuments.png) ## Conclusion Congratulations! You have completed the Quarkdown quickstart guide and are now familiar with its main features. You are ready to create your first real document. From here, you can explore more advanced topics in the rest of this wiki and in the [documentation](https://quarkdown.com/docs/). ================================================ FILE: docs/quotation-source.qd ================================================ .docname {Quotation source} .include {docs} Quarkdown allows a blockquote to have a citation source. In general, if the quote ends with a single-item unordered list, that list item is set as the citation source. You can specify the source using any of the supported unordered list bullets: `-`, `*`, `+`, although using `-` is recommended. .examplemirror > To be, or not to be, that is the question. > - William Shakespeare The layout and aesthetics are handled by the current layout theme. The source may contain inline content, including function calls. .examplemirror > Failure's not an option. It's just a step. > - Dwayne **The Rock** Johnson .examplemirror >> You miss 100% of the shots you don't take. >> - Wayne Gretzky > - Michael Scott ## Typed quotes [Typed quotes](quote-types.qd) may also have sources: .examplemirror > Tip: Try out **Quarkdown**! > - [Gio](https://github.com/iamgio) ================================================ FILE: docs/quote-types.qd ================================================ .docname {Quote types} .include {docs} If a blockquote begins with `Tip:`, `Note:`, `Warning:`, or `Important:`, Quarkdown assigns that type to the quote. The prefix is stripped off and the element is styled accordingly. .examplemirror > Note: Some useful information to keep in mind. For compatibility purposes, the GitHub-style syntax `[!NOTE]`, `[!TIP]`, `[!WARNING]`, and `[!IMPORTANT]` is also supported. .examplemirror > [!NOTE] > Some useful information to keep in mind. If the document's locale is set via [`.doclang`](document-metadata.qd) and the locale is supported, a localized prefix is displayed and styled according to the current layout theme. !(550)[Minimal theme](quote-types/minimal-theme.png "Localized prefix in the 'minimal' layout theme") !(650)[Latex theme](quote-types/latex-theme.png "Localized prefix in the 'latex' layout theme") > Tip: Quotes and [boxes](box.qd) are different ways to achieve typed alerts. ================================================ FILE: docs/range.qd ================================================ .docname {Range} .include {docs} The syntax for defining a range is **`a..b`**, where `a` and `b` are non-negative integers, for example `2..10`. You can omit either `a` or `b`, in which case the range becomes *open*. Depending on the number of delimiters provided, a range can be classified as: - Closed range: `a..b` - Open on the left end: `..b` - Open on the right end: `a..` - Open on both ends: `..` The behavior of open ranges is not universally defined. Each function that accepts a range defines its own behavior. See [`.read`](file-data.qd#file-text-content) as an example, whose strategy is common for slicing operations across the standard library. The `..` operator is syntactic sugar for the **`.range {from} {to}`** function, with the difference that the operator accepts only literal values. When the ends of the range need to be evaluated dynamically, such as through a mathematical operation, `.range` is the appropriate choice. .examplemirror .code .read {assets/point.ts} .examplemirror .code .read {assets/point.ts} lines:{5..7} .examplemirror .code .read {assets/point.ts} lines:{..3} .examplemirror .code .read {assets/point.ts} lines:{5..} ================================================ FILE: docs/sizes.qd ================================================ .docname {Sizes} .include {docs} Many functions accept sizes as arguments. This page explains the correct format for defining them. ## Single size (`Size`) When a **`Size`** value is required, strings with a **numeric value** (either integer or floating point) and a **unit** suffix are accepted. Supported units: - `px` pixels - `pt` points - `cm` centimeters - `mm` millimeters - `in` inches - `%` percentage (relative to parent) Examples of accepted values are: `12px`, `2cm`, `5.3in`, `30%`. You can omit the unit, in which case `px` is used by default. .examplemirror .container width:{50px} height:{2cm} background:{teal} ## Size group (`Sizes`) Some parameters require a group of sizes, also known as **`Sizes`**. This is required, for example, by [`.pageformat`](page-format.qd)'s `margin` parameter, and allows you to set up to four different values for each side of a rectangle. The format follows CSS conventions, with three different ways to express a size group: - **Single value:** a single [`Size`](#single-size-size) value applied to all sides (e.g., `8px`) - **Vertical/horizontal:** two [`Size`](#single-size-size) values separated by a space, applied to top-bottom and left-right respectively (e.g., `2cm 15mm`) - **TRBL:** four [`Size`](#single-size-size) values separated by a space, assigned to top, right, bottom, and left sides respectively (e.g., `2.1in 4cm 2px 2cm`) .examplemirror .container padding:{10px 60px} background:{teal} Quarkdown ================================================ FILE: docs/slides-configuration.qd ================================================ .docname {Slides configuration} .include {docs} The **`.slides`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Slides/slides.html} function allows you to override the default configuration of `slides` documents. All of its parameters are **optional**: | Parameter | Description | Accepts | Default | |----------------|---------------------------------------------------------------------------------------------------|---------------------------------|---------------------------------| | `center` | Whether content should be centered vertically. | [`Boolean`](boolean.qd) | Up to the current layout theme. | | `controls` | Whether navigation controls should be shown. | [`Boolean`](boolean.qd) | `true` | | `speakernotes` | Whether [speaker notes](slides-speaker-notes.qd) should be displayed outside of the speaker view. | [`Boolean`](boolean.qd) | `false` | | `transition` | Transition style between slides. | `none`, `fade`, `slide`, `zoom` | `slide` | | `speed` | Transition speed between slides.
Requires `transition` to be set. | `default`, `fast`, `slow` | `default` | ================================================ FILE: docs/slides-fragment.qd ================================================ .docname {Slides fragment} .include {docs} A **fragment** is an interactive section of a slide that can show or hide content when the user attempts to go to the next slide. You can create a fragment via the **`.fragment`** block function, which takes the content as a body argument. It also accepts an optional `behavior` argument with the following options: | Behavior name | Description | |----------------------|----------------------------------------------------------------------------------------| | **`show`** (default) | Starts invisible, then fades in on interaction. | | **`hide`** | Starts visible, then fades out on interaction. | | **`semihide`** | Starts visible, then fades out to 50% on interaction. | | **`showhide`** | Starts invisible, then fades in on interaction, and fades out on the next interaction. | If multiple fragments are present within the same slide, they are triggered in order. ================================================ FILE: docs/slides-speaker-notes.qd ================================================ .docname {Slides speaker notes} .include {docs} Speaker notes are helpful tools for remembering talking points, reminders, or extra details while presenting. Calling the **`.speakernote`** function adds a speaker note to the current slide. The function accepts a block of any kind of content, and you can add multiple speaker notes to the same slide. ```markdown .speakernote This is a **speaker note** for the current slide. ``` !(1000)[image](slides-speaker-notes/speaker-view.png) By default, speaker notes are displayed only when the document is viewed in the **speaker view**, which you can enable by pressing the **`S`** key while viewing the HTML presentation. > Note: Reveal.js' speaker view requires an active [web server](cli-webserver.qd). ### Outside speaker view Additionally, notes may be displayed outside the speaker view, and also in exported PDF, by enabling `.slides speakernotes:{yes}` (see [Slides configuration](slides-configuration.qd)). - HTML: !(1000)[Notes in regular presentation](slides-speaker-notes/notes-html.png) - PDF: !(1000)[Notes in PDF](slides-speaker-notes/notes-pdf.png) ================================================ FILE: docs/stacks.qd ================================================ .docname {Stacks} .include {docs} Stack functions are layout functions that arrange a group of elements according to certain layout rules. There are three of them: - **`.row`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/row.html} - **`.column`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/column.html} - **`.grid`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/grid.html} ## Blocks To understand which elements to handle, stack functions rely on the strict Markdown concept of a block, which is an isolated chunk of the document: a paragraph, a code block, a list, a quote, a figure, or another function call. .examplemirror .row gap:{1cm} A B C .examplemirror {The following example has only one item because A, B, and C are all part of the same paragraph (due to *lazy lines*):} type:{warning} .row gap:{1cm} A B C .examplemirror .row ![](assets/icon.svg) ![](assets/icon.svg) ## Parameters All stack functions accept the following optional arguments: | Parameter | Description | Accepts | |-------------|----------------------------------------------|------------------------------------------------------------------------| | `alignment` | Main axis alignment (CSS `justify-content`). | `start`, `center`, `end`, `spacebetween`, `spacearound`, `spaceevenly` | | `cross` | Cross axis alignment (CSS `align-items`). | `start`, `center`, `end`, `stretch` | | `gap` | Space between items. | [`Size`](sizes.qd) | The `grid` function requires a `columns` argument, which must be specified as an integer. .br It also provides optional `vgap` and `hgap` parameters that override the `gap` setting, allowing you to control the vertical and horizontal spacing independently. .examplemirror .grid columns:{2} alignment:{spacearound} A *B* **C** ***D*** ## Composition You can compose stack functions to create complex layouts: .examplemirror .row alignment:{spacearound} .column **Michael Scott** Dunder Mifflin Paper Company, Inc. [michaelscott@example.com](mailto:michaelscott@example.com) .column **Forrest Gump** Bubba Gump Shrimp Co. [forrestgump@example.com](mailto:forrestgump@example.com) ================================================ FILE: docs/subdocuments.qd ================================================ .docname {Subdocuments} .include {docs} When referring to a Quarkdown *document*, we are talking broadly about the group of resources that make up a Quarkdown project. Commonly, especially in the case of knowledge management and wikis like this one, pages can have links that point to other pages, building a network of interconnected ***subdocuments***. In Quarkdown, a subdocument's entry point is a source file that is referenced by another subdocument. In practice, when I do [this](quickstart.qd), I am referring to a subdocument. Every project consists of at least one subdocument: the main one, called *root*. > Note: Do not confuse subdocuments with [inclusion](including-other-quarkdown-files.qd). See [*Inclusion vs. subdocuments*](inclusion-vs-subdocuments.qd) for a comparison. ## Referencing subdocuments Whenever a Markdown link points to a local `.qd` or `.md` file, it is considered a reference to a subdocument. ```markdown [My subdocument](file.qd) ``` You can also link to a specific section within a subdocument by appending an anchor: ```markdown [Section A of my subdocument](file.qd#section-a) ``` Equivalently, you can use the **`.subdocument {path} {label?}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Ecosystem/subdocument.html} function: ```markdown .subdocument {file.qd} label:{My subdocument} ``` Unlike the link syntax, the function accepts dynamic paths, making it suitable for more complex use cases. For instance, you can use it in combination with [`.listfiles`](file-data.qd#listing-files-in-a-directory) to automatically link all documents in a directory through a [for-each loop](loops.qd): ```markdown .foreach {.listfiles {pages} sortby:{date} order:{descending}} path: .subdocument {.path} label:{.path::filename} ``` !(400)[List files](subdocuments/list-files.png) Quarkdown makes sure a file is evaluated only once, so circular and recursive references are handled gracefully. ## Context inheritance When referencing a subdocument, its content is evaluated like an independent Quarkdown document. A relevant difference is that **subdocuments inherit the referrer's context**: document metadata, customizations, functions, variables, and more. .example > `main.qd`: > ```markdown > .doctype {paged} > .theme {darko} > .pageformat margin:{1cm} > > .function {greet} > Hello! > > [Introduction](introduction.qd) > ``` > `introduction.qd`: > ```markdown > No need to set the document up again, it's already inherited! > > .greet > ``` Unlike [`.include`](including-other-quarkdown-files.qd), the context is *inherited*, not *shared*: changes made in the subdocument do not affect the referrer. .example The following example produces an error because `greet` is not defined in the referrer context: > `main.qd`: > ```markdown > [Introduction](introduction.qd) > > .greet > ``` > `introduction.qd`: > ```markdown > .function {greet} > Hello! > ``` ## Working directory The working directory of a subdocument is relative to the subdocument's entry point, not the referrer: .filetree - main.qd - pages - introduction.qd - images - image.png Whereas `main.qd` can reference `images/image.png`, `introduction.qd` should reference it as `../images/image.png`. > The [media storage system](media-storage.qd) can still store media files across subdocuments. ## Subdocument graph Subdocuments are structured in a directed graph, where the edges are the links between them. You can visualize the graph via the **`.subdocumentgraph`** function: !(700)[Graph](subdocuments/graph.png) ## Output Subdocuments are exported differently depending on the output target. ### HTML Each subdocument is exported as a separate subdirectory in the same directory as `index.html`. Configuration resources, such as themes and runtime scripts, are generated only once. [Media](media-storage.qd) is stored per subdocument instead. .filetree - MyDocument - media - theme - script - introduction - media - index.html - conclusion - media - index.html - index.html ### PDF Each subdocument is exported as a separate PDF file. .filetree - MyDocument - index.pdf - introduction.pdf - conclusion.pdf > Note: Subdocument links in PDF output are not navigable yet. If only the root subdocument is present, then only `MyDocument.pdf` is generated without the need for a directory. ### Output naming By default, subdocument output files are named after their source file name. This behavior can be customized via the `--subdoc-naming ` compiler option. This applies to all output targets, such as HTML and PDF. .example `getting-started.qd` -> `getting-started.pdf` #### Document name Launching the compiler with `--subdoc-naming document-name` names each subdocument output after their own [`.docname`](document-metadata.qd), rather than the source file name. If a subdocument does not set `.docname`, the source file name is used as a fallback. Note that duplicate document names are not handled, and may lead to name collisions and overwritten files. .example `getting-started.qd` with `.docname {Getting Started}` -> `Getting-Started.pdf` #### Minimizing name collisions Since the subdocument output files lie flatly in the same directory, it is possible that two subdocuments with the same name but from different directories end up colliding. By default, Quarkdown accepts this collision-prone behavior in order to generate human-readable URLs. If collisions cannot be avoided, launching the compiler with `--subdoc-naming collision-proof` will append a hash to the output file names. .example `getting-started.qd` -> `getting-started@12345678.pdf` ================================================ FILE: docs/syntax-of-a-function-call.qd ================================================ .docname {Syntax of a function call} .include {docs} **Functions** are the key feature of Quarkdown, distinguishing it from other Markdown dialects and many other markup languages. They are loaded from external libraries, either native (e.g., .repolink {standard library} {tree/main/quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib}) or defined in a Quarkdown source, and come in different categories: | Category | Examples | |-------------------------------------|----------------------------------------------------------| | Layout rules | `row`, `column`, `grid`, `center` | | Utility views | `tableofcontents`, `whitespace` | | Mathematical and logical operations | `sum`, `divide`, `pow`, `sin`, `isgreater` | | Control structures and statements | `if`, `foreach`, `repeat`, `var`, `let`, `function` | | File data | `include`, `csv`, `read` | | Alteration of document metadata | `docname`, `docauthor`, `doctype`, `theme`, `pageformat` | ## Basic syntax When called from a Quarkdown source, the function name is preceded by a `.` (dot) and each argument is wrapped in curly brackets. ```markdown .myfunction {arg1} {arg2} ``` .examplemirror .multiply {6} {3} ## Positional and named arguments In the previous snippet, `arg1` and `arg2` refer to the first and second parameter of the function signature respectively. These are called *positional arguments* because their meaning is determined by their position in the call. An argument can also refer to a parameter by name. These *named arguments* use the syntax `name:{arg}`. ```markdown .myfunction firstparam:{arg1} secondparam:{arg2} ``` .examplemirror {You can mix positional and named arguments to improve readability of the function call, as long as all arguments that follow a named argument are named as well.} .multiply {6} by:{3} ## Multi-line arguments Arguments can span over multiple lines. Indentation is optional and arbitrary. .examplemirror .divide { .cos {.pi} } by:{ .sum {2} {1} } ## Nested function calls Function calls can be nested in arguments, allowing you to compose complex expressions. .examplemirror .multiply {.pow {3} to:{2}} by:{.pi} ## Chaining calls Although Quarkdown exclusively relies on top-level declarations, it exploits some OOP-like syntactic sugar that greatly increases the readability of nested function calls. Consider the following call: ```markdown .sum {.subtract {.pow {3} {2}} {1}} {2} ``` This performs $ (3^2 - 1) + 2 = 10 $. As simple as it is, it could not be harder to write and read! The following call is totally equivalent: ```markdown .pow {3} {2}::subtract {1}::sum {2} ``` Much better. It now resembles the way we naturally read math. To understand how chaining works, consider this simpler example (see [*Variables*](variables.qd) to learn more about variables): ```markdown .myvar::uppercase ``` Behind the scenes, the compiler transforms this call into: ```markdown .uppercase {.myvar} ``` .examplemirror .var {myvar} {hello!} .myvar::uppercase Generally speaking, `.a::b` is transformed into `.b {.a}`, `.a::b::c` into `.c {.b {.a}}`, and so on. You can append additional arguments to any function in the chain. Just keep in mind that the chained value is always **the first argument**, positionally speaking, of the next call. .examplemirror {`\.a {x}::b {y}` is transformed into `\.b {\.a {x}} {y}`:} .sum {10} {5}::multiply {2} Many core functions are designed to be called in a chain, for example [`None` operations](none.qd#examples). ## Tight function calls A function call must normally be surrounded by whitespace, a symbol, or the beginning or end of a line. This means that a call directly adjacent to a word character is not recognized: .examplemirror type:{warning} H.text {2} script:{sub}O Wrapping the entire function call in curly braces allows it to appear anywhere, regardless of the surrounding characters. The wrapping braces are consumed by the compiler and do not appear in the output: .examplemirror H{.text {2} script:{sub}}O ## Block vs. inline function calls Function calls can appear in two contexts: inline or block. ### Inline calls An **inline** function call is preceded and/or followed by other inline content, such as text. The output of an inline function call is simply replaced in the parent's block. .examplemirror Ever wondered what **26+16** equals? It's .sum {26} {16}. Here you go. ### Block calls A **block** function call is an isolated one. For context, in Markdown, a *block* is a paragraph, a code snippet, a quote, a list, and so on. ```markdown Paragraph 1 .myfunction {arg1} {arg2} Paragraph 2 ``` ### The body argument The main difference between inline and block function calls is a special argument called **body argument**. This argument **always** refers to the **last parameter** of the signature (even if named arguments were used). A body argument expands over multiple lines, is not wrapped by brackets, and requires each line to be **indented** by at least two spaces or one tab: ```markdown .myfunction {arg1} {arg2} Body argument, line 1 and line 2. ``` The whole body must share the same indentation. The following produces unexpected results, as indentation is inconsistent: ```html .myfunction {arg1} {arg2} Body argument, line 1 and line 2. ``` ### Nesting functions in body arguments Other functions, block or inline, can be nested inside body arguments: ```html .row alignment:{center} This document was made by .docauthor .column The document name is .docname .loremipsum ``` ### Body arguments and inline calls When nested inside inline arguments, function calls are always inline. Thus, the following is invalid since body arguments are accepted only in block calls: ```markdown .center { .row Hi } ``` While this is valid as `.row` is called within a body argument: ```markdown .center .row Hi ``` ================================================ FILE: docs/table-caption.qd ================================================ .docname {Table caption} .include {docs} The Quarkdown flavor introduces table captions, which you can set by adding a line immediately below the table. The content is wrapped between double quotes, single quotes, or parentheses, exactly like the image *title* attribute and [code captions](code-caption.qd). .examplemirror | Country | Capital | |---------|---------| | England | London | | Italy | Rome | | France | Paris | "Capitals of Europe" .examplemirror {The caption must be located immediately next to the table, on a new line. The following example renders a paragraph instead of a caption:} type:{warning} | Country | Capital | |---------|---------| | England | London | | Italy | Rome | | France | Paris | "Capitals of Europe" ## Changing position The relative position of captions can be customized. See [Caption position](caption-position.qd) for further information. ## Numbering Tables can also be **numbered**. See [Numbering](numbering.qd) for further information. ================================================ FILE: docs/table-generation.qd ================================================ .docname {Table generation} .include {docs} ## Table generation by columns The **`.table`** function takes a single block argument which is an [iterable](iterable.qd) of **tables**. The result of the call is a **new table** that combines the supplied tables **by columns**. In the following example, `.repeat` is used, which, like other supported [loops](loops.qd), returns the results from each iteration as an iterable. .examplemirror .table .repeat {3} n: | Column .n | |-----------| | Cell .n:1 | | Cell .n:2 | | Cell .n:3 | ## Table generation by rows The **`.tablebyrows`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.TableComputation/tablebyrows.html} function takes two arguments: an optional iterable of headers and an iterable of rows. .examplemirror .var {headers} - Name - Age - City .tablebyrows {.headers} - - John - 25 - NY - - Lisa - 32 - LA - - Mike - 19 - CHI .examplemirror {If no headers are provided, the table will have no header row. Additionally, dynamic content can be used to generate cells:} .tablebyrows .repeat {3} y: .repeat {3} x: Cell .x:.y ================================================ FILE: docs/table-manipulation.qd ================================================ .docname {Table manipulation} .include {docs} This page describes table manipulation functions .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.TableComputation} that allow you to sort, filter, and compute values from any kind of table, including plain Markdown ones and those [loaded from CSV](file-data.qd#table-from-csv). .examplemirror .tablesort {2} order:{descending} .csv {assets/people.csv} ## Sort rows The **`.tablesort`** function sorts a table based on the values of a specific column. | Parameter | Description | Accepts | |-----------|---------------------------------------|-------------------------------------| | `column` | Index of the column, starting from 1. | 1 to number of columns. | | `order` | Sorting order. | `ascending` (default), `descending` | .examplemirror .tablesort {2} order:{descending} | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | .examplemirror {The sorting follows the most natural way for humans to sort strings (*alphanumeric* sorting):} .tablesort {2} | Item | Price | |----------|-------| | Pencil | $1 | | Eraser | $0.50 | | Backpack | $20 | | Notebook | $3 | ## Filter rows The **`.tablefilter`** function keeps or removes rows based on the values of a specific column. | Parameter | Description | Accepts | |-----------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| | `column` | Index of the column, starting from 1. | 1 to number of columns. | | `filter` | Lambda that returns whether each row should be kept, with the value of its cell in the corresponding column as input. | [`Dynamic`](typing.qd) -> [`Boolean`](boolean.qd) [lambda](lambda.qd) | .examplemirror .tablefilter {2} {@lambda x: .x::isgreater {20}} | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | ## Compute/aggregate columns The **`.tablecompute`** function computes the cells in a column and appends the result to a new row. | Parameter | Description | Accepts | |-----------|--------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| | `column` | Index of the column, starting from 1. | 1 to number of columns. | | `compute` | Lambda that returns the computed value, with the collection of the cells in the column as input. | [`Iterable`](iterable.qd) -> [`Dynamic`](typing.qd) [lambda](lambda.qd) | See [*Iterable*](iterable.qd) to learn more about available operations on collections. .examplemirror .tablecompute {2} {@lambda x: .x::average::round} | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | ## Composition You can chain multiple table operations. The order of operations goes from inner to outer: .examplemirror .tablecompute {2} {@lambda x: .x::average::round} .tablesort {2} | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | ## Retrieve columns The **`.tablecolumn`** function extracts values from the cells of a specific column and returns them as an [Iterable](iterable.qd). | Parameter | Description | Accepts | |-----------|---------------------------------------|-------------------------| | `column` | Index of the column, starting from 1. | 1 to number of columns. | .examplemirror .var {values} .tablecolumn {2} | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | .values::first ## Bulk-retrieve columns Additionally, the **`.tablecolumns`** function returns all the columns from the table as an iterable of iterables. This is more efficient when you need to access multiple columns from the same table, compared to calling `.tablecolumn` multiple times. .examplemirror .var {columns} .tablecolumns | Name | Age | City | |------|-----|------| | John | 25 | NY | | Lisa | 32 | LA | | Mike | 19 | CHI | .foreach {.columns} col: .col::first ================================================ FILE: docs/table-of-contents.qd ================================================ .docname {Table of contents} .include {docs} A table of contents provides a quick overview of your document by displaying links to headings in a visual hierarchy. Readers can click on any entry to jump directly to that section. You can see a table of contents right now in the right sidebar, or at the end of the page if you're on a mobile device. You can display a table of contents through the **`.tableofcontents`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/tableofcontents.html} function. ## Basic usage The function accepts the following optional parameters: | Parameter | Description | Accepts | Default | |-----------|-------------|---------|---------| | `title` | Title that precedes the table of contents. If unset, it is automatically localized. | Inline content | Automatically localized if [`.doclang`](document-metadata.qd)'s locale is supported (e.g., `Table of Contents` for English) | | `maxdepth` | Maximum heading level to display. For example, `maxdepth:{2}` collects `#` and `##` headings, but not `###` or deeper. | Integer | `3` | .exampleoutput {!(550)[Table of contents](table-of-contents/basic.png)} .tableofcontents maxdepth:{2} ## A ... ### A.A ... ### A.B ... ## B ... ## C ### C.A #### C.A.A ... ## Behavior by document type The table of contents adapts to different document types: - In **`paged`** and **`slides`** documents, each entry also displays the corresponding page number. - In **`slides`** documents, the table of contents has a constrained height and becomes vertically scrollable when content overflows. ## Theming [Layout themes](themes.qd) greatly influence the appearance of the table of contents. .example !(550)[Table of contents with minimal theme](table-of-contents/minimal-theme.png "'minimal' theme") ## Ignoring specific headings Sometimes you want certain headings to appear in your document but not in the table of contents. [Decorative headings](headings.qd#decorative-headings) serve this purpose: they are not numbered and are excluded from the table of contents. To mark a heading as decorative, append `!` to the last `#` sign: ```markdown ##! A decorative heading ``` ## Custom numbering You can customize how headings are numbered in the table of contents via the `.numbering` function. See [Numbering](numbering.qd) for more information. ## Heading options The heading that precedes the table of contents can be customized with the following parameters: | Parameter | Description | Accepts | Default | |---------------------|-------------------------------------------------------------------------------------------------------------------------------|---------|-----------------------------------------------------| | `headingdepth` | Depth of the heading that precedes the table of contents. | Integer | Depends on document type (1 for most, 3 for `docs`) | | `breakpage` | Whether the heading triggers an automatic [page break](page-break.qd#automatic-break). | Boolean | `yes` | | `numberheading` | Whether the heading should be [numbered](numbering.qd) and have its position tracked in the document hierarchy. | Boolean | `no` | | `indexheading` | Whether the heading should be included in the table of contents itself. Implicitly enables `numberheading`. | Boolean | `no` | ## Focusing entries The `focus` parameter highlights a specific entry in the table of contents, drawing attention to the current section. .exampleoutput {!(550)[Table of contents with focus](table-of-contents/focused.png)} prelude:{Adding `focus:{A.B}` to the previous example highlights that entry:} .tableofcontents maxdepth:{2} focus:{A.B} This feature is particularly useful in presentations, where you might want to show a mini table of contents at the beginning of each chapter to orient your audience. ### Using markers for chapter navigation For slide presentations, you can combine `focus` with **markers** to create elegant chapter navigation. The `.marker` function creates an invisible level-0 heading, which is not normally possible with the standard `#`-based syntax. Here is a practical pattern for chapter slides: ```markdown .function {chapter} name: .tableofcontents maxdepth:{0} focus:{.name} .marker {.name} ``` You can then invoke it with `.chapter {My chapter}` at the start of each section. Setting `maxdepth:{0}` ensures only markers appear in the table of contents, creating a clean chapter-level overview rather than showing all headings. ================================================ FILE: docs/tex-formulae.qd ================================================ .docname {TeX formulae} .include {docs} Quarkdown natively supports TeX math equations and formulae. When rendering to HTML, this feature is powered by [KaTeX](https://www.katex.org). ## Inline Inline equations can be created by wrapping text between two `$` symbols. Both delimiters must be preceded and followed by a whitespace (or beginning/end of the line). .examplemirror Let $ \overline v = \frac {\Delta x} {\Delta t} $ be the **average velocity** of an object. ## One-line block Block equations are usually visually centered and share the same syntax as inline ones, but need to be isolated from other content: .examplemirror The following function is a **Fourier Transform**: $ F(u) = \int^{+\infty}_{-\infty} f(x) e^{-i 2\pi x} dx $ > Note: This syntax does **not** interrupt paragraphs, so make sure to space blocks properly. If the paragraph is not interrupted, the formula is recognized as inline due to Markdown's *lazy lines*. > > .examplemirror > The following function is a **Fourier Transform**: > $ F(u) = \int^{+\infty}_{-\infty} f(x) e^{-i 2\pi x} dx $ ## Multiline block A block formula can span over multiple lines thanks to a syntax similar to fenced code blocks, using three `$` symbols as delimiters. .examplemirror $$$ f(x) = \begin{cases} 0 & \text{if } x = 0 \\ 1 & \text{if } x \neq 0 \end{cases} $$$ ## Macros Quarkdown supports the creation of TeX macros via the `.texmacro` function. See [*TeX macros*](tex-macros.qd) for more information. ================================================ FILE: docs/tex-macros.qd ================================================ .docname {TeX macros} .include {docs} When writing [TeX formulae](tex-formulae.qd), you may want to use custom macros. The **`.texmacro {name} {content}`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Document/texmacro.html} function defines a new macro that can be used in equations. .examplemirror .texmacro {\gradient} {\nabla} $ \gradient f $ As with every Quarkdown function, you can use the last argument as a [*block argument*](syntax-of-a-function-call.qd#block-vs-inline-function-calls). .example ```text .texmacro {\gradient} \nabla ``` ## Parameters Macros can feature a variable number of parameters, which are referenced inside the macro content as `#1`, `#2`, and so on. .examplemirror .texmacro {\sumlim} \sum_{#1}^{#2} $ \sumlim{i=1}{n} a_i $ ## Composing You can define multiple macros and combine them, as you would do in LaTeX: .examplemirror .texmacro {\hello} \text {Hello, \textit {world}} .texmacro {\highlight} \colorbox{blue}{#1} $ \highlight{\hello} $ You can also compose them on top of other macros: .examplemirror .texmacro {\hello} \text {Hello, \textit {world}} .texmacro {\highlighthello} \colorbox{blue}{\hello} $ \highlighthello $ ================================================ FILE: docs/text-symbols.qd ================================================ .docname {Text symbols} .include {docs} Quarkdown features automatic text replacement for commonly used UTF-8 symbols. Text is automatically replaced wherever inline content is accepted (for example, paragraphs, blockquotes, and boxes, but NOT code blocks/spans, math, URLs, etc.). | Syntax | Rendered | Conditions | |--------|-------------|-----------------------------------------------------------------------------------------------| | `--` | — (em-dash) | | | `-` | – (en-dash) | Preceded by a word character and a whitespace, followed by a whitespace and a word character. | | `...` | … | Either at the beginning or end of a word, not in between. | | `->` | → | | | `<-` | ← | | | `=>` | ⇒ | | | `<==` | ⇐ | | | `>=` | ≥ | | | `<=` | ≤ | | | `!=` | ≠ | | | `+-` | ± | | | `'` | ' | Not preceded by a word character, followed by a word character. | | `'` | ' | Not preceded by a whitespace. | | `"` | " | Not preceded by a word character, followed by a word character. | | `"` | " | Not preceded by a whitespace, not followed by a word character. | | `(C)` | © | | | `(R)` | ® | | | `(TM)` | ™ | | .examplemirror "Quarkdown"--a typesetting system--supports automatic text replacements. ## Escaping Escaping a sequence with a backslash `\` prevents the text from being replaced: .examplemirror \-> ================================================ FILE: docs/text.qd ================================================ .docname {Text} .include {docs} The **`.text`** inline function provides extensive text formatting that cannot be expressed in plain Markdown. | Parameter | Accepts | |------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| | **`text`** (mandatory) | Inline content. | | **`size`** | `tiny` (50%), `small` (75%), `normal` (100%), `medium` (125%), `large` (150%), `larger` (200%), `huge` (300%) | | **`weight`** | `normal`, `bold` | | **`style`** | `normal`, `italic` | | **`decoration`** | `underline`, `overline`, `underoverline`, `strikethrough`, `all` | | **`case`** | `uppercase`, `lowercase`, `capitalize` | | **`variant`** | `normal`, `smallcaps` | | **`script`** | `sub` (subscript), `sup` (superscript) | | **`url`** | URL to link to. If set, the text becomes a link. If the URL is set but empty, the text content itself is used as the URL (assuming it represents a valid URL). | .examplemirror ##! A demo of .text {Quarkdown} variant:{smallcaps} The .text {quick brown fox} size:{large} decoration:{underoverline} jumps over the lazy dog. ================================================ FILE: docs/themes.qd ================================================ .docname {Themes} .include {docs} A theme defines the look and feel of your Quarkdown document. More themes are planned for the future. Themes are split into two groups: *color* themes, which define the color scheme of a document, and *layout* themes, which set the general structural rules of the layout. Combining them allows you to create a document that truly stands out. You can set a theme via the **`.theme {colortheme} layout:{layouttheme}`** function. ### Color themes - `paperwhite` (default) - `darko` - `galactic` - `beaver` ### Layout themes - `latex` (default) - `hyperlegible` - `minimal` - `beamer` --- Some suggested combinations are: - `paperwhite+latex` (LaTeX look, great for `paged` documents) - `galactic+hyperlegible` (this wiki) - `darko+minimal` - `beaver+beamer` (Beamer look, great for academic-style presentations) ## Contributing .repolink {Theme contributions} {tree/main/quarkdown-html/src/main/scss} are welcome. Please make sure themes work well with all three document types before submitting. The .repolink {Mock document} {tree/main/mock#readme} is a great way to test themes against a variety of different elements. ================================================ FILE: docs/typing.qd ================================================ .docname {Typing} .include {docs} Quarkdown is **dynamically typed**: every value that may be passed to a function argument is wrapped in a `DynamicValue` object. This object can represent any supported type, and it carries the information needed to convert itself to the expected type when required. The dynamic value is then *adapted* or converted to the type expected by the signature of the native function (written in Kotlin, which is strongly typed) at invocation time. If a conversion cannot be made, an error occurs. This invoke-time adaptation reduces constraints, allowing the same value to be handled differently depending on its context. .examplemirror .var {myvar} {true} .if {.myvar} My value is .uppercase {.myvar} In the previous example: - `.var`'s signature accepts a `DynamicValue`. - `.if` takes a [`Boolean`](boolean.qd), so the dynamic `true` value kept in the `myvar` variable is converted to boolean. - `.uppercase` takes a `String`, so the `true` value is used as a string. See the *Value types* section of this wiki to see all supported types. ## Example: source + result The following example is a simplified version of a function defined in this wiki, which shows a Quarkdown code snippet and its visual result right below it: .examplemirror .function {sourceresult} source: .code {markdown} .source .source .sourceresult ##! Quarkdown Quarkdown was born in **.sum {2000} {24}** The previous example shows how versatile Quarkdown values are. In the function declaration, the `.source` argument is retrieved twice: - In [`.code`](code.qd), which expects a string, so the source is read as-is and inserted in a code block. - At the top level: the Quarkdown source is automatically adapted to the context, so it is parsed as rich Markdown content. ================================================ FILE: docs/variables.qd ================================================ .docname {Variables} .include {docs} Variables allow you to store and reuse values throughout your document. They are essential for avoiding repetition, creating dynamic content, and building reusable components. ## Defining a variable Use **`.var {name} {value}`** to define a variable. The `value` is a [dynamic value](typing.qd), meaning it can be of any type: text, numbers, booleans, or even complex elements like layouts. ```markdown .var {name} {Quarkdown} ``` ## Accessing a variable Once defined, you can access a variable by calling it as a parameter-less function: .examplemirror .var {name} {Quarkdown} Hello, **.name**! ## Reassigning a variable You can update a variable's value at any point. There are two equivalent ways to do this: - `.myvar {newvalue}` - `.var {myvar} {newvalue}` .examplemirror .var {mynumber} {5} .mynumber .mynumber {.mynumber::sum {1}} .mynumber ## Block variables As mentioned in [Syntax of a function call](syntax-of-a-function-call.qd#the-body-argument): > A body argument always refers to the last parameter of the signature. This means a variable's value can span multiple lines and contain complex block content, not just simple inline values. This is powerful for storing reusable layouts, styled containers, or any structured content. .examplemirror .var {myrow} .row gap:{2cm} A B C .container background:{teal} padding:{1cm} .myrow ## Scoped variables For temporary variables that should only exist within a limited scope, see the [`.let`](let.qd) function. Unlike `.var`, which creates document-wide variables, `.let` creates variables that exist only within a lambda block. ================================================ FILE: docs/whitespace.qd ================================================ .docname {Whitespace} .include {docs} You can add blank space anywhere via the **`.whitespace`** function .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Layout/whitespace.html} , which accepts optional `width` and `height` [Size](sizes.qd#single-size-size) arguments. - If you set neither argument, a simple whitespace character is rendered (` `), which is useful for adding blank lines. - If you set at least one argument, a rectangle of that size is rendered. Note that this might not always work outside of [layout functions](stacks.qd). .examplemirror Line 1 Line 2 .whitespace Line 3 .examplemirror .row A .whitespace width:{1cm} B .whitespace width:{2cm} C ================================================ FILE: docs/xy-chart.qd ================================================ .docname {XY chart} .include {docs} The **`.xychart`** .docslink {quarkdown-stdlib/com.quarkdown.stdlib.module.Mermaid/xychart.html} function allows you to plot 2D line and bar charts in a pure, flexible Quarkdown fashion, through the power of [Mermaid diagrams](mermaid-diagrams.qd). All parameters, except for `values`, are optional. | Parameter | Description | Accepts | |-----------|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------| | `lines` | Whether to draw lines (defaults to true). | [Boolean](boolean.qd) | | `bars` | Whether to draw bars (defaults to false). | [Boolean](boolean.qd) | | `x` | Label for the X axis. | String | | `xrange` | Numerical range for the X axis. Can be open-ended. Incompatible with `xtags`. | [Range](range.qd) | | `xtags` | Categorical tags for the X axis. Incompatible with `xrange`. | [Iterable](iterable.qd) | | `y` | Label for the Y axis. | String | | `yrange` | Range for the Y axis. Can be open-ended. | [Range](range.qd) | | `caption` | Caption. If present, the chart is [numbered](numbering.qd) as a figure. | String | | `values` | Y values to plot. | [Iterable](iterable.qd) of points (single line), or iterable of iterables of points (multiple lines) | ## Axis ranges `xrange` and `yrange` define the boundaries of the visible area of the chart through the usual `x..y` [Range](range.qd) syntax. The range may also be open on either end, or both: - If open on the left end, the range starts from the minimum value among the plotted points. - If open on the right end, the range ends at the maximum value among the plotted points. For instance, `..` (infinite) is the default range, which shows the area between the minimum and maximum values. ## Possible input sources ### From static list (single line) You can represent `values` as a Markdown list, just like all iterables. .examplemirror .xychart bars:{yes} x:{Months} y:{Revenue} yrange:{100..} - 250 - 500 - 350 - 450 - 400 - 500 - 600 ### From static list (multiple lines) In case of nested lists (iterable of iterables), multiple lines will be plotted: .examplemirror .xychart x:{Months} y:{Revenue} - - 250 - 500 - 350 - 450 - 400 - - 400 - 150 - 200 - 400 - 450 ### From iterations Quarkdown [loops](loops.qd) return an iterable containing the result of each iteration, making them a suitable input for `values`, especially in combination with [math](math.qd) functions. .examplemirror .xychart .repeat {100} .1::pow {2}::divide {100} .repeat {100} .1::logn::multiply {10} ### From CSV/table [Table manipulation](table-manipulation.qd) functions such as [`.tablecolumn`](table-manipulation.qd#retrieve-columns) can extract values from the columns of a table as iterables. .examplemirror .xychart .tablecolumn {2} .csv {assets/sales.csv} If you need to access multiple columns from the same table, consider calling `.tablecolumns` for efficiency, which returns a collection of columns. You can then perform any [`Iterable`](iterable.qd) operation on it. .examplemirror {In the following example, the chart displays two lines: one for the second and one for the third column of the CSV. Additionally, the first column of the table is used as a set of categorical tags for the X axis.} .var {columns} .tablecolumns .csv {assets/sales.csv} .xychart xtags:{.columns::first} x:{Years} y:{Sales} .columns::second .columns::third ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ org.gradle.parallel=true kotlin.code.style=official org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # 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://www.apache.org/licenses/LICENSE-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. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: mock/README.md ================================================

Mock document

# Mock ***Mock***, written in Quarkdown, is a comprehensive collection of visual elements offered by the language, making it ideal for exploring and understanding its key features — all while playing with a concrete outcome in the form of pages or slides. This document is also a great theme testing site. To compile: `quarkdown c mock/main.qd` - Add `-p` to open in-browser. Add `-p -w` to enable live reloading. - Add `--pdf` to compile to PDF. ================================================ FILE: mock/alignment.qd ================================================ # Alignment .function {aligncontent} alignment: .align {.alignment} ##! .alignment::capitalize The quick brown fox jumps over the lazy dog. The brown fox jumps over the lazy dog. The fox jumps over the dog. .aligncontent {start} .aligncontent {center} .aligncontent {end} ================================================ FILE: mock/bibliography/bibliography.bib ================================================ @article{einstein, author = "Albert Einstein", title = "Zur Elektrodynamik bewegter Körper. (German) [On the electrodynamics of moving bodies]", journal = "Annalen der Physik", volume = "322", number = "10", year = "1905", DOI = "http://dx.doi.org/10.1002/andp.19053221004" } @book{hawking, author = "Stephen Hawking", title = "A Brief History of Time", publisher = "Bantam Books", year = "1988", ISBN = "978-0553109535" } @misc{knuthwebsite, author = "Donald Knuth", title = "Knuth: Computers and Typesetting", url = "http://www-cs-faculty.stanford.edu/\~uno/abcde.html" } ================================================ FILE: mock/bibliography.qd ================================================ # Bibliography Einstein's publication .cite {einstein} in 1905 revolutionized the field of physics, particularly in the realm of special relativity. His work laid the foundation for modern theoretical physics and has been cited extensively in subsequent research. Similarly, Hawking's book .cite {hawking} has had a profound impact on our understanding of cosmology and black holes. .bibliography {bibliography/bibliography.bib} style:{ieee} breakpage:{no} ================================================ FILE: mock/boxes.qd ================================================ # Boxes .var {boxtext} Don't fight it, use what happens. That's what makes life fun. That you can make these decisions. That you can create the world that you want. Once you learn the technique, ohhh! Turn you loose on the world; you become a tiger. - The quick brown fox jumps over the lazy dog. - The quick brown fox jumps over the lazy dog. .box .boxtext .box {The quick brown fox jumps over the lazy dog} .boxtext <<< ## Alerts .box {The quick brown fox jumps over the lazy dog} type:{tip} .boxtext .box {The quick brown fox jumps over the lazy dog} type:{note} .boxtext <<< .box {The quick brown fox jumps over the lazy dog} type:{warning} .boxtext .box {The quick brown fox jumps over the lazy dog} type:{error} .boxtext ================================================ FILE: mock/code/Wrapper.java ================================================ public final class Wrapper { private final T value; public Wrapper(T value) { this.value = value; } public final T getValue() { return this.value; } } ================================================ FILE: mock/code.qd ================================================ # Code #### Default .code lang:{java} .read {code/Wrapper.java} #### With caption .code lang:{java} caption:{A wrapper class} .read {code/Wrapper.java} <<< #### Focused .code lang:{java} focus:{4..6} .read {code/Wrapper.java} #### Without line numbers .code lang:{java} linenumbers:{no} .read {code/Wrapper.java} ================================================ FILE: mock/collapsibles.qd ================================================ # Collapsibles #### Block .collapse {A collapsible block. *Click me!*} open:{yes} .loremipsum #### Inline Here is an inline collapsible text, which you .textcollapse {have just opened. Congratulations} short:{can click here}! ================================================ FILE: mock/colorpreview.qd ================================================ # Color preview `#000000` `#FFFFFF` `#32A852` `rgb(255, 0, 255)` `hsl(350, 55, 40)` `hsv(190, 50, 90)` ================================================ FILE: mock/crossreferences.qd ================================================ # Cross-references {#cross-references} .ref {cross-references} shows various examples of cross-references. For instance, the Quarkdown icon shown in .ref {qd-icon} features a circular design. ![Quarkdown icon](images/icon.svg "The Quarkdown icon") {#qd-icon} In mathematics, a circle with center at $ (a, b) $ and radius $ r $ is defined by .ref {circle-eq}: $ (x - a)^2 + (y - b)^2 = r^2 $ {#circle-eq} .ref {circle-eq} is a special case of the more general equation .ref {ellipse-eq}, which defines an ellipse. $ \frac{(x - a)^2}{r_x^2} + \frac{(y - b)^2}{r_y^2} = 1 $ {#ellipse-eq} An ellipse is not a polygon. .ref {polygons} shows polygons and their properties. | Polygon | Sides | Internal angle sum | |---------------|-------|--------------------| | Triangle | 3 | 180° | | Quadrilateral | 4 | 360° | | Pentagon | 5 | 540° | | Hexagon | 6 | 720° | {#polygons} ================================================ FILE: mock/errors.qd ================================================ # Errors .row alignment:{Error demonstration!} Hello! ================================================ FILE: mock/footnotes.qd ================================================ # Footnotes The search for planets beyond our solar system - exoplanets - has transformed modern astronomy. Ever since the first confirmed detection of an exoplanet orbiting a sun-like star in 1995[^pegasi], astronomers have cataloged thousands more, revealing an incredible diversity of worlds. Many exoplanets are found using the transit method, where astronomers detect a slight dip in a star's brightness when a planet passes in front of it[^transit: The transit method was used extensively by NASA's *Kepler* mission.]. This method, popularized by missions like *Kepler*, has uncovered planets of all sizes--from Earth-like rocky worlds to gas giants larger than Jupiter. Another technique is the radial velocity method, which detects the gravitational wobble a planet induces in its host star[^doppler]. This was how the first exoplanet, 51 Pegasi b, was confirmed[^pegasi]. Combining both transit and radial velocity data allows scientists to estimate a planet's density and composition. Surprisingly, many exoplanets challenge our understanding of planetary systems. Hot Jupiters, for example, are massive gas giants orbiting extremely close to their stars--something not seen in our own solar system[^: Hot Jupiters are believed to have migrated inward from their original formation zone.]. These discoveries force astronomers to refine models of planetary formation and migration[^doppler]. With next-generation telescopes like the James Webb Space Telescope (JWST), scientists hope to study the atmospheres of distant exoplanets in greater detail[^transit]. By analyzing the starlight passing through a planet's atmosphere during a transit, researchers can search for signatures of water, methane, or even biosignatures--potential signs of life. [^pegasi]: Mayor & Queloz, 1995--discovery of *51 Pegasi b*. [^doppler]: Radial velocity method measures the Doppler shift in a star's spectrum. ================================================ FILE: mock/headings.qd ================================================ # Headings .initnumbering #! First-level heading .loremipsum ##! Second-level heading .loremipsum ###! Third-level heading .loremipsum <<< ####! Fourth-level heading .loremipsum #####! Fifth-level heading .loremipsum ######! Sixth-level heading .loremipsum ================================================ FILE: mock/icons.qd ================================================ # Icons .var {icons} - heart - heart-fill - arrow-down - arrow-down-fill - airplane-fill - warning-triangle - github .foreach {.icons} .codespan {.1} becomes .icon {.1}; ================================================ FILE: mock/images.qd ================================================ # Images ![Quarkdown icon](images/icon.svg) The quick brown fox jumps over the lazy dog. This is a separator text. ![Quarkdown icon](images/icon.svg "") .conditionalpagebreak ontype:{slides} ![Quarkdown icon](images/icon.svg "The Quarkdown icon.") <<< !(20%)[Quarkdown icon](images/icon.svg "20% of page width.") .conditionalpagebreak ontype:{slides} !(40%)[Quarkdown icon](images/icon.svg "40% of page width.") .conditionalpagebreak ontype:{slides} !(60%)[Quarkdown icon](images/icon.svg "60% of page width.") <<< ![Sky](images/sky.jpg "A high-resolution picture.") <<< ## Floating .var {floatalignments} - start - end --- .foreach {.floatalignments} The more that you practice, the more you're able to visualize things. Beauty is everywhere; you only have to look to see it. However you want to change this, that's the way it should be. It's a lot of fun. If you comply with that rule, how can you go wrong? Get a nice, even distribution of paint all through the bristles. We don't know where that goes--it doesn't matter at this point. Let's just have a good time. Anything you are willing to practice, you can do! Think like a cloud. .float {.1} !(118px)[Sky](images/sky.jpg) The more that you practice, the more you're able to visualize things. Beauty is everywhere; you only have to look to see it. However you want to change this, that's the way it should be. It's a lot of fun. If you comply with that rule, how can you go wrong? Get a nice, even distribution of paint all through the bristles. We don't know where that goes--it doesn't matter at this point. Let's just have a good time. Anything you are willing to practice, you can do! Think like a cloud. --- <<< ## Clipping .center .clip {circle} .container alignment:{center} background:{salmon} foreground:{white} padding:{1cm} .container background:{lemonchiffon} ###! Clipping in a **circle**! .clip {circle} !(40%)[Sky](images/sky.jpg "A nice sky.") *Photo credits: [Pixabay](https://www.pexels.com/photo/blue-skies-53594/)* ================================================ FILE: mock/lists.qd ================================================ # Lists ## Unordered #### Tight My favorite foods: - Some delicious pasta - A huge pizza - A lot of sushi - A tasty burger #### Loose My favorite foods: - Some delicious pasta - A huge pizza - A lot of sushi - A tasty burger <<< #### Nested - .loremipsum - The quick brown fox jumps over the lazy dog - The quick brown fox jumps over the lazy dog - The quick brown fox jumps over the lazy dog - The quick brown fox jumps over the lazy dog - The quick brown fox jumps over the lazy dog - The quick brown fox jumps over the lazy dog <<< ## Ordered #### Tight My favorite foods: 1. Some delicious pasta 2. A huge pizza 3. A lot of sushi 4. A tasty burger #### Loose My favorite foods: 1. Some delicious pasta 2. A huge pizza 3. A lot of sushi 4. A tasty burger <<< #### Nested 1. .loremipsum 1. The quick brown fox jumps over the lazy dog 2. The quick brown fox jumps over the lazy dog 1. The quick brown fox jumps over the lazy dog 1. The quick brown fox jumps over the lazy dog 1. The quick brown fox jumps over the lazy dog 2. The quick brown fox jumps over the lazy dog <<< ## Tasks #### Tight Today's shopping list: - [ ] Some delicious pasta - [x] A huge pizza - [x] A lot of sushi - [ ] A tasty burger #### Loose Today's shopping list: - [ ] Some delicious pasta - [x] A huge pizza - [x] A lot of sushi - [ ] A tasty burger ================================================ FILE: mock/localization.qd ================================================ # Ad-hoc locale adaptation Quarkdown can dynamically adapt to different locales. Try changing the document language to *Chinese* in `setup.qd` and see this page magically morph! --- 在這個快速變化的時代,生活節奏越來越快,人們開始重新思考什麼才是真正重要的。無論是城市的喧囂還是鄉間的寧靜,內心的平衡才是追求的目標。 陽光穿過窗戶,灑在舊書的頁面上,時間彷彿靜止了一刻。記憶中的味道、聲音與畫面交織成過去與現在的橋樑。每一次呼吸,都像是與世界重新連結的契機。 當科技不斷前進,我們是否還記得最初的感動?在無數的選擇之中,找到屬於自己的節奏,才能真正走出屬於自己的路。 語言,是連結思想的工具;而文字,則是記錄靈魂的方式。願每一段文字,都是一場靜靜綻放的旅程。 - 滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。 - 滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。 - 滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。滚滚长江东逝水,浪花淘尽英雄。是非成败转头空,青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢,古今多少事, 都付笑谈中。 ================================================ FILE: mock/main.qd ================================================ .theme {paperwhite} layout:{latex} .doctype {paged} .includeall - setup.qd - headings.qd - paragraphs.qd - lists.qd - images.qd - tables.qd - code.qd - textformatting.qd - colorpreview.qd - quotes.qd - boxes.qd - math.qd - footnotes.qd - mermaid.qd - collapsibles.qd - errors.qd - icons.qd - separators.qd - alignment.qd - stacks.qd - crossreferences.qd - bibliography.qd - localization.qd ================================================ FILE: mock/math.qd ================================================ # Math #### Inline Let $ F(u) $ be the *Fourier Transform* of the function $ f(x) $. #### Block $ F(u) = \int_{-\infty}^{\infty} f(x) e^{-2 \pi i u x} dx $ #### Multiline block $$$ f(x) = \begin{cases} x^2, & \text{if } x \ge 0 \\ -x, & \text{if } x < 0 \end{cases} $$$ #### Numbered block $ E = mc^2 $ {#_} .texmacro {\binomdef} \frac{#1!}{#2!(#1 - #2)!} > Tip: Quarkdown supports TeX macros. > > $ \binom{n}{k} = \binomdef{n}{k} $ ================================================ FILE: mock/mermaid/class.mmd ================================================ classDiagram class Bank { +name: string +address: string } class Customer { +name: string +id: int } class BankAccount { <> +id: int +balance: double +deposit(amount: double) +withdraw(amount: double) } class Transaction { +amount: double +date: date +execute() } class Loan { +id: int +amount: double +interestRate: double +approve() } Bank "1" o-- "*" Customer : manages Customer "1" --> "*" BankAccount : owns Customer "1" --> "*" Loan : has BankAccount "1" --> "*" Transaction : records ================================================ FILE: mock/mermaid/flow.mmd ================================================ flowchart TD A([Start]) --> B[Enter username and password] B --> C{Correct?} C -- Yes --> D[Redirect to dashboard] C -- No --> E[Show error message] D --> F([END]) E --> F ================================================ FILE: mock/mermaid/git.mmd ================================================ gitGraph: commit "Ashish" branch newbranch checkout newbranch commit id:"1111" commit tag:"test" checkout main commit type: HIGHLIGHT commit merge newbranch commit branch b2 commit ================================================ FILE: mock/mermaid/pie.mmd ================================================ pie showData "Sleep" : 8 "Work" : 9 "Exercise" : 1 "Leisure" : 4 "Meals" : 2 ================================================ FILE: mock/mermaid/sequence.mmd ================================================ sequenceDiagram Alice ->> Bob: Hello Bob, how are you? alt sick Bob ->> Alice: Not so good :( else well Bob ->> Alice: Feeling fresh like a daisy. end opt Bob ->> Alice: Thanks for asking! end ================================================ FILE: mock/mermaid.qd ================================================ # Mermaid diagrams ## XY chart .let {100} n: .xychart yrange:{..100} .repeat {.n} .1::pow {2}::divide {100} .repeat {.n} .1::logn::multiply {5} .repeat {.n} .1::divide {3}::sin::multiply {5}::sum {40} .repeat {.n} .1::divide {3}::cos::multiply {5}::sum {40} .xychart lines:{no} bars:{yes} x:{Months} y:{Revenue} - 5000 - 6000 - 7500 - 8200 - 9500 - 10500 - 11000 - 10200 - 9200 - 8500 - 7000 - 6000 <<< ## Class diagram .mermaid caption:{Class diagram of a bank system.} .read {mermaid/class.mmd} <<< ## Sequence diagram .mermaid caption:{Sequence diagram of a communication.} .read {mermaid/sequence.mmd} <<< ## Flowchart .mermaid caption:{Flowchart of the dashboard.} .read {mermaid/flow.mmd} <<< ## Git graph .mermaid caption:{Graph of a Git repository.} .read {mermaid/git.mmd} .conditionalpagebreak {slides} <<< ## Pie chart .mermaid caption:{Pie chart of a daily routine.} .read {mermaid/pie.mmd} ================================================ FILE: mock/paragraphs.qd ================================================ # Paragraphs All you need is a dream in your heart, and an almighty knife. Learn when to stop. No pressure. Just relax and watch it happen. Get away from those little Christmas tree things we used to make in school. Let your heart be your guide. A thin paint will stick to a thick paint. There comes a nice little fluffer. It's a very cold picture, I may have to go get my coat. It’s about to freeze me to death. And I know you're saying, 'Oh Bob, you've done it this time.' And you may be right. You have to make almighty decisions when you're the creator. You create the dream - then you bring it into your world. Anytime you learn something your time and energy are not wasted. Just think about these things in your mind and drop em' on canvas. At home you have unlimited time. Isn't that fantastic? You can just push a little tree out of your brush like that. Little trees and bushes grow however makes them happy. The light is your friend. Preserve it. Let's make some happy little clouds in our world. When you do it your way you can go anywhere you choose. You can create anything that makes you happy. Zip. That easy. If you don't think every day is a good day - try missing a few. You'll see. Get tough with it, get strong. Think about a cloud. Just float around and be there. Here we're limited by the time we have. With something so strong, a little bit can go a long way. You don't have to spend all your time thinking about what you're doing, you just let it happen. Just let your mind wander and enjoy. This should make you happy. A big strong tree needs big strong roots. ================================================ FILE: mock/quotes.qd ================================================ # Blockquotes > The quick brown fox jumps over the lazy dog. > Let your imagination be your guide. Maybe we got a few little happy bushes here, just covered with snow. A big strong tree needs big strong roots. > - Bob Ross >> You miss 100% of the shots you don't take. >> - Wayne Gretzky > - _Michael Scott_ <<< ## Alerts > Tip: Some useful information. > The quick brown fox jumps over the lazy dog. > Note: Something to be aware of. > The quick brown fox jumps over the lazy dog. > Warning: Something to be cautious about. > The quick brown fox jumps over the lazy dog. > Important: Some critical information. > The quick brown fox jumps over the lazy dog ================================================ FILE: mock/separators.qd ================================================ # Separators #### Horizontal The quick brown fox jumps over the lazy dog. --- The quick brown fox jumps over the lazy dog. .whitespace #### Vertical .row .container A B C --- .container D E F ================================================ FILE: mock/setup.qd ================================================ .docname {Quarkdown Mock} .docauthor {Giorgio Garofalo} .doclang {English} .pageformat borderbottom:{1px} bordercolor:{grey} .include {paper} .function {conditionalpagebreak} ontype: .if {.doctype::equals {.ontype}} <<< .function {initnumbering} .resetpagenumber .pagemargin {bottominside} *.lastheading depth:{1}* .pagemargin {bottomoutside} .currentpage .footer Quarkdown .center #! .docname .docauthor (c) 2024-2025 --- .abstract Welcome to [Quarkdown](https://github.com/iamgio)'s mock document. This comprehensive document serves as a **detailed reference** guide for all the visual elements that can be featured in a Quarkdown-generated document, and is structured to provide **clear and concise examples** of each. It is designed to be a resource for the interested in creating, refining and testing their own themes. Whether you are looking to experiment with **color schemes, typography, or layout designs**, or just taking a look at some Quarkdown snippets, this mock document will provide the support you need. .whitespace height:{1cm} .center To compile this document, run: **`quarkdown c mock/main.qd -p`** Different themes and document types may be tested by changing the first lines of `main.qd`. .whitespace height:{3cm} .conditionalpagebreak ontype:{slides} - [Quarkdown on GitHub](https://github.com/iamgio/quarkdown) - [Quarkdown Wiki](https://github.com/iamgio/quarkdown) - Several quotes from this document were taken from [Bob Ross Lipsum](https://www.bobrosslipsum.com) .tableofcontents ================================================ FILE: mock/stacks.qd ================================================ # Layout stacks Here positioning techniques will be used: rows, columns, grids and containers. .row alignment:{center} A B C D --- .row alignment:{spacearound} A B C D --- .column A B C --- .grid columns:{3} A B C D E F <<< .row alignment:{spacearound} .row Left ##! Title Right --- .column cross:{start} Top ##! Title Bottom --- .row alignment:{spacebetween} gap:{1cm} .container ##! Container 1 .loremipsum .container ##! Container 2 .loremipsum --- <<< .grid columns:{2} gap:{1cm} .repeat {4} .container ##! Container .1 .loremipsum --- <<< .var {skyimg} ![Sky](images/sky.jpg) .grid columns:{2} gap:{1cm} .column ##! Some nice clouds! .row gap:{1cm} A B C .repeat {2} .skyimg .container There it is. In nature, dead trees are just as normal as live trees. Just a happy little shadow that lives in there. If you didn't have baby clouds, you wouldn't have big clouds. --- That's it. .whitespace .clip {circle} .skyimg ================================================ FILE: mock/tables.qd ================================================ # Tables | Name | Age | City | |------------|-----|--------------| | Alice | 30 | New York | | Bob | 25 | San Francisco| | Charlie | 35 | Los Angeles | The quick brown fox jumps over the lazy dog. This is a separator text. | Name | Age | City | |------------|-----|--------------| | Alice | 30 | New York | | Bob | 25 | San Francisco| | Charlie | 35 | Los Angeles | "User information." | | Jump | Move left | Move right | Ability | Sprint | |--------------|:--------:|:---------:|:----------:|:-------:|:------:| | **Player 1** | W, Space | A | D | Shift | CTRL | | **Player 2** | Up | Left | Right | X | Z | "Key bindings of the game." ================================================ FILE: mock/textformatting.qd ================================================ # Text formatting ## *Emphasis* **You can't have light without dark.** You can't know *happiness* unless you've known **sorrow**. Let's have a ~~nice~~ *tree right here*. Now __we don't want him to get lonely__, so we'll give him a little _friend_. We start with a ***vision*** in our **_heart_, and ~~we put it~~ on canvas**. If I *paint something*, **I don't want to *have* to explain** what it is. ## `Code` After running **`quarkdown c file.qd -p`**, a webserver will run on port `8089`. It may also be changed it via `--server-port `. Combining `-p` with `-w` enables live content reload! ## [Link](https://github.com/iamgio/quarkdown/) Did you know [Quarkdown's wiki](https://quarkdown.com/wiki) is a great place to start? Check it out! <<< ## Advanced formatting .text {This} size:{large} decoration:{underoverline} is an .text {example} size:{larger} style:{italic} of .text {what you can do} decoration:{underline} with just a .text {few things} weight:{bold} decoration:{strikethrough}, a little imagination and a happy dream in your heart. .text {That easy} variant:{smallcaps}. .text {The} size:{tiny} .text {quick} size:{small} .text {brown} size:{normal} .text {fox} size:{medium} .text {jumps} size:{larger} .text {over} size:{large} .text {the lazy dog} size:{huge}. .text {The quick} decoration:{underline} .text {brown} decoration:{overline} .text {fox} decoration:{underoverline} .text {jumps over} decoration:{strikethrough} .text {the lazy dog} decoration:{all}. .text {The quick} case:{lowercase} .text {brown fox} case:{uppercase} .text {jumps over the lazy dog} case:{capitalize}. .text {The quick brown fox jumps over the lazy dog} variant:{smallcaps}. ================================================ FILE: quarkdown-cli/LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Copyright (C) 2025 Giorgio Garofalo (iamgio) Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: quarkdown-cli/build.gradle.kts ================================================ plugins { kotlin("jvm") application } dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.apache.pdfbox:pdfbox:3.0.6") implementation(project(":quarkdown-core")) implementation(project(":quarkdown-html")) implementation(project(":quarkdown-plaintext")) implementation(project(":quarkdown-server")) implementation(project(":quarkdown-interaction")) implementation(project(":quarkdown-stdlib")) implementation(project(":quarkdown-lsp")) implementation("com.github.ajalt.clikt:clikt:5.1.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("io.methvin:directory-watcher:0.19.1") } application { mainClass.set("com.quarkdown.cli.QuarkdownCliKt") } // Writes the project version to a file in the resources directory, so it can be accessed at runtime. val writeVersionFile by tasks.registering { val version = project.parent?.version ?: "unknown" val versionFile = "version.txt" val outputFile = layout.projectDirectory.file("src/main/resources/$versionFile").asFile doLast { outputFile.writeText(version.toString()) } } tasks.processResources { dependsOn(writeVersionFile) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/CliOptions.kt ================================================ package com.quarkdown.cli import com.quarkdown.cli.renderer.RendererRetriever import java.io.File /** * Options that affect the behavior of the Quarkdown CLI, especially I/O. * For pipeline-related options, see [com.quarkdown.core.pipeline.PipelineOptions]. * @param source main source file to process * @param outputDirectory the output directory to save resource in, if set * @param libraryDirectory the directory to load .qd library files from * @param rendererName name of the renderer to use to generate the output for * @param clean whether to clean the output directory before generating new files * @param pipe whether to output the rendered result to standard output, suitable for piping * @param nodePath path to the Node.js executable * @param npmPath path to the npm executable * @param exportPdf whether to generate a PDF file * @param noPdfSandbox whether to disable the Chrome sandbox for PDF export */ data class CliOptions( val source: File?, val outputDirectory: File?, val libraryDirectory: File?, val rendererName: String, val clean: Boolean, val pipe: Boolean, val nodePath: String, val npmPath: String, val exportPdf: Boolean = false, val noPdfSandbox: Boolean = false, ) { /** * The rendering target to generate the output for. * For instance HTML or PDF. */ val renderer by lazy { RendererRetriever(this).getRenderer() } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/PipelineInitialization.kt ================================================ package com.quarkdown.cli import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.LibraryExporter import com.quarkdown.core.log.DebugFormatter import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.rendering.RenderingComponents import com.quarkdown.stdlib.Stdlib /** * Utility to initialize a [Pipeline]. */ object PipelineInitialization { /** * Initializes a [Pipeline] with the given [flavor]. * @param flavor flavor to use across the pipeline * @param loadableLibraryExporters exporters of external libraries that can be loaded by the user * @param options options that define the behavior of the pipeline * @param printOutput whether to output the rendered result to standard output, suitable for piping * @param renderer function that provides the rendering components given a renderer factory and context * @return the new pipeline */ fun init( flavor: MarkdownFlavor, loadableLibraryExporters: Set, options: PipelineOptions, printOutput: Boolean, renderer: (RendererFactory, Context) -> RenderingComponents, ): Pipeline { // Libraries to load. val libraries: Set = LibraryExporter.exportAll(Stdlib) val loadableLibraries: Set = LibraryExporter.exportAll(*loadableLibraryExporters.toTypedArray()) // Actions run after each stage of the pipeline. val hooks = PipelineHooks( afterRegisteringLibraries = { libs -> Log.debug { "Libraries: " + DebugFormatter.formatLibraries(libs) } }, afterParsing = { document -> Log.debug { "AST:\n" + DebugFormatter.formatAST(document) } }, afterAllRendering = { output -> Log.debug { "Final Output:\n$output" } if (printOutput) { println(output) } }, ) // The pipeline. return Pipeline( context = MutableContext(flavor, loadableLibraries = loadableLibraries), options = options, libraries = libraries, renderer = renderer, hooks = hooks, ) } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/QuarkdownCli.kt ================================================ package com.quarkdown.cli import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.main import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.parameters.options.versionOption import com.quarkdown.cli.creator.command.CreateProjectCommand import com.quarkdown.cli.exec.CompileCommand import com.quarkdown.cli.exec.ReplCommand import com.quarkdown.cli.lsp.LanguageServerCommand import com.quarkdown.cli.server.StartWebServerCommand /** * Main command of Quarkdown CLI, which delegates to subcommands. */ class QuarkdownCommand : CliktCommand() { init { val version = this::class.java.getResource("/version.txt")?.readText() ?: "unknown" versionOption(version) } override fun aliases() = mapOf("c" to listOf(CompileCommand().commandName)) override fun run() {} } fun main(args: Array) = QuarkdownCommand() .subcommands( CompileCommand(), ReplCommand(), StartWebServerCommand(), CreateProjectCommand(), LanguageServerCommand(), ).main(args) ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/ProjectCreator.kt ================================================ package com.quarkdown.cli.creator import com.quarkdown.cli.creator.content.ProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.template.ProjectCreatorTemplatePlaceholders import com.quarkdown.cli.creator.template.ProjectCreatorTemplateProcessorFactory import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.template.TemplateProcessor /** * Generator of resources for a new Quarkdown project via [createResources]. * Based on different properties, the resources and their content may vary. * @param templateProcessorFactory factory that generates the template for the main file * @param initialContentSupplier supplier of the initial content (code content and assets) * @param mainFileName name of the main file, without extension * @see com.quarkdown.cli.creator.template.DefaultProjectCreatorTemplateProcessorFactory * @see com.quarkdown.cli.creator.content.DefaultProjectCreatorInitialContentSupplier * @see com.quarkdown.cli.creator.content.EmptyProjectCreatorInitialContentSupplier */ class ProjectCreator( private val templateProcessorFactory: ProjectCreatorTemplateProcessorFactory, private val initialContentSupplier: ProjectCreatorInitialContentSupplier, private val mainFileName: String, ) { /** * Finalizes the template processor by injecting into it: * - The main file name * - The initial example content, processed via the same template processor * @param template the template processor to finalize * @return the finalized template processor */ private fun finalizeTemplateProcessor(template: TemplateProcessor): TemplateProcessor { // The main file name is injected before copying, // so that the initial content template can reference it. template.optionalValue(ProjectCreatorTemplatePlaceholders.MAIN_FILE, mainFileName) // Initial content is processed via the same template processor. val initialContentCode = initialContentSupplier.templateCodeContent ?.let { template.copy(text = it).process().trim() } // Processed initial content is injected into the main template. template.optionalValue(ProjectCreatorTemplatePlaceholders.INITIAL_CONTENT, initialContentCode) return template } /** * @return a collection of resources generated by this project creator */ fun createResources(): Set { val resources = this.templateProcessorFactory.createFilenameMappings().map { (fileName, processor) -> TextOutputArtifact( fileName ?: mainFileName, finalizeTemplateProcessor(processor).process().trim(), ArtifactType.QUARKDOWN, ) } return buildSet { addAll(resources) addAll(initialContentSupplier.createResources()) } } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/command/CreateProjectCommand.kt ================================================ package com.quarkdown.cli.creator.command import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.default import com.github.ajalt.clikt.parameters.options.check import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.prompt import com.github.ajalt.clikt.parameters.types.enum import com.github.ajalt.clikt.parameters.types.file import com.github.ajalt.mordant.rendering.TextColors.cyan import com.github.ajalt.mordant.rendering.TextColors.green import com.github.ajalt.mordant.rendering.TextStyles.bold import com.github.ajalt.mordant.rendering.TextStyles.dim import com.quarkdown.cli.creator.ProjectCreator import com.quarkdown.cli.creator.content.DefaultProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.content.DefaultTheme import com.quarkdown.cli.creator.content.DocsProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.content.EmptyProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.template.DefaultProjectCreatorTemplateProcessorFactory import com.quarkdown.cli.creator.template.DocsProjectCreatorTemplateProcessorFactory import com.quarkdown.core.document.DocumentAuthor import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.pipeline.output.visitor.saveTo import java.io.File /** * Default name of the default directory to save the generated files in. */ private const val DEFAULT_DIRECTORY = "." /** * Default name of the main file, if not specified by the user. */ private const val DEFAULT_MAIN_FILE_NAME = "main" /** * Formats a prompt label with an optional hint, using Mordant styling. * The label is bold and the hint, if present, is dimmed. */ private fun styledPrompt( label: String, hint: String? = null, ): String = if (hint != null) "${bold(label)} ${dim(hint)}" else bold(label) /** * Command to create a new Quarkdown project with a default template. */ class CreateProjectCommand : CliktCommand("create") { private val directory: File by argument(help = "Project directory") .file( canBeFile = false, canBeDir = true, mustExist = false, ).default(File(DEFAULT_DIRECTORY)) private val mainFileName: String? by option("--main-file", help = "Main file name") private val name: String? by option("--name", help = "Project name") .prompt(styledPrompt("Project name")) private val authorsRaw: String by option("--authors", help = "Project authors") .prompt(styledPrompt("Authors", "(comma-separated)")) private val authors: List by lazy { authorsRaw .split(",") .filter { it.isNotBlank() } .map { DocumentAuthor(it.trim()) } } private val type: DocumentType by option("--type", help = "Document type") .enum { it.quarkdownName } .prompt( styledPrompt("Document type", "(${DocumentType.entries.joinToString(", ") { it.quarkdownName }})"), default = DocumentType.PLAIN, ) private val description: String by option("--description", help = "Document description") .prompt(styledPrompt("Description")) private val keywordsRaw: String? by option("--keywords", help = "Document keywords (comma-separated)") private val keywords: List by lazy { keywordsRaw ?.split(",") ?.filter { it.isNotBlank() } ?.map { it.trim() } ?: emptyList() } private fun findLocale(language: String): Locale? = LocaleLoader.SYSTEM.find(language) private val languageRaw: String? by option("--lang", help = "Document language") .prompt(styledPrompt("Language", "(e.g. English, French, zh, it)")) .check( lazyMessage = { "$it is not a valid locale." }, validator = { it.isBlank() || findLocale(it) != null }, ) private val language: Locale? by lazy { languageRaw?.let(::findLocale) } private val colorTheme: String? by option("--color-theme", help = "Color theme") private val layoutTheme: String? by option("--layout-theme", help = "Layout theme") private val noInitialContent: Boolean by option("-e", "--empty", help = "Do not include initial content") .flag() private fun createDocumentInfo() = DocumentInfo( name = name?.takeUnless { it.isBlank() } ?: directory.name, description = description.takeUnless { it.isBlank() }, authors = authors.toMutableList(), keywords = keywords, type = type, locale = language, theme = DocumentTheme( colorTheme ?: DefaultTheme.getColorTheme(type), layoutTheme ?: DefaultTheme.getLayoutTheme(type), ), ) private fun createProjectCreator(): ProjectCreator { val mainFileName = this.mainFileName ?: DEFAULT_MAIN_FILE_NAME val documentInfo = this.createDocumentInfo() val isDocs = documentInfo.type == DocumentType.DOCS return ProjectCreator( templateProcessorFactory = when { isDocs -> DocsProjectCreatorTemplateProcessorFactory(documentInfo) else -> DefaultProjectCreatorTemplateProcessorFactory(documentInfo) }, initialContentSupplier = when { noInitialContent -> EmptyProjectCreatorInitialContentSupplier() isDocs -> DocsProjectCreatorInitialContentSupplier() else -> DefaultProjectCreatorInitialContentSupplier() }, mainFileName, ) } override fun run() { val creator = this.createProjectCreator() directory.mkdirs() creator.createResources().forEach { it.saveTo(directory) } val mainFile = "${this.mainFileName ?: DEFAULT_MAIN_FILE_NAME}.qd" echo() echo(" ${(green + bold)("Project created")} in ${bold(directory.canonicalPath)}") echo() echo(" ${dim("Compile:")} ${cyan("quarkdown c $mainFile")}") echo(" ${dim("Live preview:")} ${cyan("quarkdown c $mainFile -p -w")}") } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/content/DefaultProjectCreatorInitialContentSupplier.kt ================================================ package com.quarkdown.cli.creator.content import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.LazyOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup private const val RESOURCES_PATH = "/creator/" private const val CODE_CONTENT_PATH = RESOURCES_PATH + "initialcontent.qd.jte" private const val LOGO = "logo.png" private const val IMAGES_GROUP_NAME = "image" /** * A [ProjectCreatorInitialContentSupplier] that provides some initial content for introduction purposes: * - A simple Quarkdown code snippet * - An image of the Quarkdown logo */ class DefaultProjectCreatorInitialContentSupplier : ProjectCreatorInitialContentSupplier { override val templateCodeContent: String get() = javaClass.getResourceAsStream(CODE_CONTENT_PATH)!!.bufferedReader().readText() private val imageGroup: OutputResource get() = OutputResourceGroup( IMAGES_GROUP_NAME, setOf( LazyOutputArtifact.internal( RESOURCES_PATH + LOGO, LOGO, ArtifactType.AUTO, ), ), ) override fun createResources(): Set = setOf(imageGroup) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/content/DefaultTheme.kt ================================================ package com.quarkdown.cli.creator.content import com.quarkdown.core.document.DocumentType /** * Utilities to determine the default [com.quarkdown.core.document.DocumentTheme] components for a new Quarkdown project, * based on its type. */ object DefaultTheme { private const val DEFAULT_LAYOUT_THEME = "latex" private const val DEFAULT_DOCS_LAYOUT_THEME = "hyperlegible" private const val DEFAULT_COLOR_THEME = "paperwhite" private const val DEFAULT_DOCS_COLOR_THEME = "galactic" /** * @param type the document type to get the layout theme for * @return the default layout theme for the given document type */ fun getLayoutTheme(type: DocumentType): String = when (type) { DocumentType.DOCS -> DEFAULT_DOCS_LAYOUT_THEME else -> DEFAULT_LAYOUT_THEME } /** * @param type the document type to get the color theme for * @return the default color theme for the given document type */ fun getColorTheme(type: DocumentType): String = when (type) { DocumentType.DOCS -> DEFAULT_DOCS_COLOR_THEME else -> DEFAULT_COLOR_THEME } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/content/DocsProjectCreatorInitialContentSupplier.kt ================================================ package com.quarkdown.cli.creator.content import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.LazyOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource private const val RESOURCES_PATH = "/creator/docs/" private val RESOURCES = arrayOf("_nav.qd", "page-1.qd", "page-2.qd", "page-3.qd") /** * A [ProjectCreatorInitialContentSupplier] that provides a template for a documentation project via the `docs` library. * The template includes a navigation file and three page files, with some example content. */ class DocsProjectCreatorInitialContentSupplier : ProjectCreatorInitialContentSupplier { override val templateCodeContent: String get() = DefaultProjectCreatorInitialContentSupplier().templateCodeContent override fun createResources(): Set = RESOURCES .map { page -> LazyOutputArtifact.internal( RESOURCES_PATH + page, page, ArtifactType.AUTO, ) }.toSet() } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/content/EmptyProjectCreatorInitialContentSupplier.kt ================================================ package com.quarkdown.cli.creator.content import com.quarkdown.core.pipeline.output.OutputResource /** * A [ProjectCreatorInitialContentSupplier] that provides no initial content or resources. */ class EmptyProjectCreatorInitialContentSupplier : ProjectCreatorInitialContentSupplier { override val templateCodeContent: String? = null override fun createResources(): Set = emptySet() } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/content/ProjectCreatorInitialContentSupplier.kt ================================================ package com.quarkdown.cli.creator.content import com.quarkdown.cli.creator.template.ProjectCreatorTemplateProcessorFactory import com.quarkdown.core.pipeline.output.OutputResource /** * Supplier of the initial content of a new Quarkdown project. * This includes: * - Code content that goes into the main file * - Additional resources (e.g. assets) * @see com.quarkdown.cli.creator.ProjectCreator */ interface ProjectCreatorInitialContentSupplier { /** * @return the code content that is inserted into the main file. * This code will be processed by the same template processor used by the [com.quarkdown.cli.creator.ProjectCreator]. * If `null`, no code content is provided. * @see ProjectCreatorTemplateProcessorFactory */ val templateCodeContent: String? /** * @return a collection of additional resources that are generated by this supplier. * This may include assets, additional files, etc. */ fun createResources(): Set } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/template/DefaultProjectCreatorTemplateProcessorFactory.kt ================================================ package com.quarkdown.cli.creator.template import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.template.TemplateProcessor private const val TEMPLATE = "/creator/main.qd.jte" /** * Implementation of [ProjectCreatorTemplateProcessorFactory] * based on the default template, which relies on document information * to fill placeholders. * @param info document information to inject into the template * @param template name of the template resource to use * @see ProjectCreatorTemplatePlaceholders */ class DefaultProjectCreatorTemplateProcessorFactory( private val info: DocumentInfo, private val template: String = TEMPLATE, ) : ProjectCreatorTemplateProcessorFactory { override fun create(): TemplateProcessor = with(ProjectCreatorTemplatePlaceholders) { TemplateProcessor.fromResourceName(template).apply { optionalValue(NAME, info.name) optionalValue(DESCRIPTION, info.description) conditional(KEYWORDS, info.keywords.isNotEmpty()) iterable(KEYWORDS, info.keywords) conditional(AUTHORS, info.authors.isNotEmpty()) iterable(AUTHORS, info.authors.map { it.name }) optionalValue(TYPE, info.type.quarkdownName) conditional(IS_DOCS, info.type == DocumentType.DOCS) optionalValue(LANGUAGE, info.locale?.displayName) conditional(HAS_THEME, info.theme?.hasComponent == true) optionalValue(COLOR_THEME, info.theme?.color) optionalValue(LAYOUT_THEME, info.theme?.layout) conditional(USE_PAGE_COUNTER, info.type == DocumentType.PAGED) } } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/template/DocsProjectCreatorTemplateProcessorFactory.kt ================================================ package com.quarkdown.cli.creator.template import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.template.TemplateProcessor private const val TEMPLATE = "/creator/docs/main.qd.jte" /** * Implementation of [ProjectCreatorTemplateProcessorFactory] for `docs` projects, * which relies on [DefaultProjectCreatorTemplateProcessorFactory], but saved to `_setup.qd`, * plus an additional mapping for the main file that uses some example content. * @param info document information to inject into the template * @see DefaultProjectCreatorTemplateProcessorFactory */ class DocsProjectCreatorTemplateProcessorFactory( private val info: DocumentInfo, ) : ProjectCreatorTemplateProcessorFactory by DefaultProjectCreatorTemplateProcessorFactory(info) { override fun createFilenameMappings(): Map = mapOf( "_setup" to create(), null to DefaultProjectCreatorTemplateProcessorFactory(info, TEMPLATE).create(), ) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/template/ProjectCreatorTemplatePlaceholders.kt ================================================ package com.quarkdown.cli.creator.template /** * Placeholders used in project creator templates. */ object ProjectCreatorTemplatePlaceholders { const val NAME = "name" const val DESCRIPTION = "description" const val KEYWORDS = "keywords" const val AUTHORS = "authors" const val TYPE = "type" const val IS_DOCS = "docs" const val LANGUAGE = "lang" const val HAS_THEME = "theme" const val COLOR_THEME = "colorTheme" const val LAYOUT_THEME = "layoutTheme" const val USE_PAGE_COUNTER = "pageCounter" const val MAIN_FILE = "mainFile" const val INITIAL_CONTENT = "initialContent" } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/creator/template/ProjectCreatorTemplateProcessorFactory.kt ================================================ package com.quarkdown.cli.creator.template import com.quarkdown.core.template.TemplateProcessor /** * Factory that creates one or multiple [TemplateProcessor]s that generate files of a new Quarkdown project. * @see TemplateProcessor */ interface ProjectCreatorTemplateProcessorFactory { /** * @return the [TemplateProcessor] that processes a file of a new Quarkdown project */ fun create(): TemplateProcessor /** * @return a mapping of file names to [TemplateProcessor]s that process files of a new Quarkdown project. * The file name `null` is reserved for the main file */ fun createFilenameMappings(): Map = mapOf(null to create()) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/CompileCommand.kt ================================================ package com.quarkdown.cli.exec import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.file import com.quarkdown.cli.CliOptions import com.quarkdown.cli.exec.strategy.FileExecutionStrategy import com.quarkdown.cli.server.WebServerOptions import com.quarkdown.cli.server.browserLauncherOption import com.quarkdown.cli.util.MillisStopwatch import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.interaction.Env import com.quarkdown.server.ServerEndpoints import com.quarkdown.server.browser.BrowserLauncher import com.quarkdown.server.browser.DefaultBrowserLauncher import com.quarkdown.server.message.ServerMessageSession import java.io.File /** * Command to compile a Quarkdown file into an output. * @see FileExecutionStrategy */ class CompileCommand : ExecuteCommand("compile") { /** * Quarkdown source file to process. */ private val source: File by argument(help = "Source file").file( mustExist = true, canBeDir = false, mustBeReadable = true, ) /** * Whether to export to PDF. */ private val exportPdf: Boolean by option("--pdf", help = "Export to PDF").flag() /** * Whether to disable Chrome sandbox for PDF export from HTML. Potentially unsafe. */ private val noPdfSandbox: Boolean by option( "--pdf-no-sandbox", help = "(Unsafe) Disable Chrome sandbox for PDF export", envvar = Env.NO_SANDBOX, ).flag() /** * When enabled, the rendered content (NOT post-rendered) is printed to stdout and nothing else is logged, * suitable for piping the output to other commands. */ private val pipe: Boolean by option("--pipe", help = "Print only the rendered content to stdout").flag() /** * Optional browser to open the served file in, if preview is enabled. */ private val browser: BrowserLauncher? by browserLauncherOption( default = DefaultBrowserLauncher(), shouldValidate = { preview }, ) /** * Session to communicate with the server in order to trigger reloads of the preview. */ private val reloadSession: ServerMessageSession by lazy { ServerMessageSession( port = super.serverPort, endpoint = ServerEndpoints.RELOAD_LIVE_PREVIEW, ) } /** * Finalizes the CLI options before execution. * - Sets the source file * - Disables file output when in pipe mode * - Sets PDF export options */ override fun finalizeCliOptions(original: CliOptions) = original.copy( source = source, outputDirectory = original.outputDirectory.takeUnless { pipe }, pipe = pipe, exportPdf = exportPdf, noPdfSandbox = noPdfSandbox, ) /** * Stopwatch to measure the duration of the compilation. */ @get:Synchronized @set:Synchronized private lateinit var stopwatch: MillisStopwatch override fun createExecutionStrategy(cliOptions: CliOptions) = FileExecutionStrategy(source) override fun preExecute( cliOptions: CliOptions, pipelineOptions: PipelineOptions, ) { this.stopwatch = MillisStopwatch() } private fun logCompletion(output: File) { if (super.preview && this::stopwatch.isInitialized) { val elapsed = stopwatch.elapsedMillis() Log.success("in ${elapsed}ms") } else { Log.success("@ ${output.absolutePath}") } } override fun postExecute( outcome: ExecutionOutcome, cliOptions: CliOptions, pipelineOptions: PipelineOptions, ) { if (cliOptions.pipe) { // No action needed when in pipe mode. return } if (outcome.directory == null) { Log.warn("Unexpected null output directory during compilation post-processing") return } this.logCompletion(output = outcome.directory) if (super.preview) { runServerCommunication(outcome.directory) } } private fun runServerCommunication(directory: File) { // Communicates with the server to reload the requested resources. // If enabled and the server is not running, also starts the server // (this is shorthand for `quarkdown start -f -p -b default`). runServerCommunication( WebServerOptions( port = super.serverPort, targetFile = directory, browserLauncher = browser, preferLivePreviewUrl = super.preview && super.watch, ), reloadSession, ) } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/Execute.kt ================================================ package com.quarkdown.cli.exec import com.quarkdown.cli.CliOptions import com.quarkdown.cli.PipelineInitialization import com.quarkdown.cli.exec.strategy.PipelineExecutionStrategy import com.quarkdown.cli.lib.QdLibraries import com.quarkdown.cli.server.WebServerOptions import com.quarkdown.cli.server.WebServerStarter import com.quarkdown.cli.util.cleanDirectory import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.error.FunctionCallRuntimeException import com.quarkdown.core.function.library.LibraryExporter import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.output.visitor.saveTo import com.quarkdown.server.message.ServerMessage import com.quarkdown.server.message.ServerMessageSession import java.io.IOException import kotlin.system.exitProcess /** * Executes a complete Quarkdown pipeline. * @param executionStrategy launch strategy of the pipeline, e.g. from file or REPL * @param cliOptions options that define the behavior of the CLI, especially I/O * @param pipelineOptions options that define the behavior of the pipeline * @return the output file or directory, if any, associated with the executed pipeline */ fun runQuarkdown( executionStrategy: PipelineExecutionStrategy, cliOptions: CliOptions, pipelineOptions: PipelineOptions, ): ExecutionOutcome { // Flavor to use across the pipeline. val flavor: MarkdownFlavor = QuarkdownFlavor // External libraries loaded from .qd files. val libraries: Set = try { cliOptions.libraryDirectory?.let(QdLibraries::fromDirectory) ?: emptySet() } catch (e: Exception) { Log.warn(e.message ?: "") emptySet() } // The pipeline that contains all the stages to go through, // from the source input to the final output. val pipeline: Pipeline = PipelineInitialization.init( flavor, libraries, pipelineOptions, printOutput = cliOptions.pipe, cliOptions.renderer, ) // Output directory to save the generated resources in. val outputDirectory = cliOptions.outputDirectory try { // Cleans the output directory if enabled in options. if (cliOptions.clean) { outputDirectory?.cleanDirectory() } // Pipeline execution and output resource retrieving. val resource = executionStrategy.execute(pipeline) // Exports the generated resources to file if enabled in options. val childDirectory = outputDirectory?.let { resource?.saveTo(it) } return ExecutionOutcome(resource, childDirectory, pipeline) } catch (e: PipelineException) { val targetException = (e as? FunctionCallRuntimeException)?.cause ?: e targetException.printStackTrace() exitProcess(e.code) } } /** * Communicates with the server to reload the requested resources. * If the session is not active, starts the server. * @param options information to start the web server * @param session the session to communicate with the server to handle preview reloads */ fun runServerCommunication( options: WebServerOptions, session: ServerMessageSession, ) { if (!session.isConnected) { Log.info("Starting server...") WebServerStarter.start(options, session, onSessionReady = { runServerCommunication(options, session) }) return } // Sends a reload message to the server. try { ServerMessage().send(session) return } catch (e: IOException) { Log.error("Could not communicate with the server on port ${options.port}: ${e.message}") Log.debug(e) } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/ExecuteCommand.kt ================================================ package com.quarkdown.cli.exec import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.choice import com.github.ajalt.clikt.parameters.types.file import com.github.ajalt.clikt.parameters.types.int import com.quarkdown.cli.CliOptions import com.quarkdown.cli.exec.strategy.PipelineExecutionStrategy import com.quarkdown.cli.server.DEFAULT_SERVER_PORT import com.quarkdown.cli.util.thisExecutableFile import com.quarkdown.cli.watcher.DirectoryWatcher import com.quarkdown.core.document.sub.SubdocumentOutputNaming import com.quarkdown.core.log.Log import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.core.pipeline.error.StrictPipelineErrorHandler import com.quarkdown.core.util.kebabCaseName import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NpmWrapper import java.io.File /** * Name of the default directory to save output files in. * It can be overridden by the user. */ const val DEFAULT_OUTPUT_DIRECTORY = "output" /** * Name of the default directory to load libraries from. * The default value is relative to the executable JAR file location, and points to the `lib/qd` directory of the distribution archive. * It can be overridden by the user. */ val DEFAULT_LIBRARY_DIRECTORY = ".." + File.separator + "lib" + File.separator + "qd" /** * Template for Quarkdown commands that launch a complete pipeline and produce output files. * @param name name of the command * @see CompileCommand * @see ReplCommand */ abstract class ExecuteCommand( name: String, ) : CliktCommand(name) { /** * @param cliOptions options that define the behavior of the CLI (already finalized by [finalizeCliOptions]) * @return strategy to launch the pipeline, e.g. from file or REPL */ protected abstract fun createExecutionStrategy(cliOptions: CliOptions): PipelineExecutionStrategy /** * Finalizes the CLI options before running the pipeline by creating a new instance. * The [original] options are created by [ExecuteCommand]'s (= this base class) properties. * @param original original CLI options * @return finalized CLI options */ protected open fun finalizeCliOptions(original: CliOptions): CliOptions = original /** * Optional output directory. * If not set, the output is saved in [DEFAULT_OUTPUT_DIRECTORY]. */ private val outputDirectory: File? by option("-o", "--out", help = "Output directory") .file( mustExist = false, canBeFile = false, canBeDir = true, ).default(File(DEFAULT_OUTPUT_DIRECTORY)) /** * Optional name of the output resource, to be located in [outputDirectory]. * If not set, defaults to the value of `.docname`. */ private val resourceName: String? by option( "--out-name", help = "Name of the output resource, to be located in the output directory. Note: special characters will be sanitized to dashes.", ) /** * Optional library directory. * If not set, the program looks for libraries in [DEFAULT_LIBRARY_DIRECTORY], relative to the executable JAR file location. */ private val libraryDirectory: File? by option("-l", "--libs", help = "Library directory") .file( mustExist = true, canBeFile = false, canBeDir = true, ).default(File(thisExecutableFile?.parentFile, DEFAULT_LIBRARY_DIRECTORY)) /** * The rendering target to generate output for. */ private val renderer: String by option( "-r", "--render", help = "Rendering target to generate output for", ).default("html") /** * When enabled, the rendering stage produces pretty output code. */ private val prettyOutput: Boolean by option("--pretty", help = "Pretty output").flag() /** * When enabled, the rendered code isn't wrapped in a template code. * For example, an HTML wrapper may add `......`, with the content injected in `body`. * @see com.quarkdown.core.template.TemplateProcessor */ private val noWrap: Boolean by option("--nowrap", help = "Don't wrap output").flag() /** * When enabled, the process is aborted whenever any pipeline error occurs. * By default, this is disabled and error messages are displayed in the final document without killing the pipeline. */ private val strict: Boolean by option("--strict", help = "Exit on error").flag() /** * When enabled, the output directory is cleaned before generating new files. */ private val clean: Boolean by option("--clean", help = "Clean output directory").flag() /** * When enabled, the program does not store any media (e.g. images) into the output directory `media` directory * and nodes that reference those media objects are not updated to reflect the new local path. */ private val noMediaStorage: Boolean by option("--no-media-storage", help = "Disables media storage").flag() /** * The strategy used to determine subdocument output file names. */ private val subdocumentNaming: SubdocumentOutputNaming by option( "--subdoc-naming", help = "Subdocument output naming strategy", ).choice(choices = SubdocumentOutputNaming.entries.associateBy { it.kebabCaseName }) .default(SubdocumentOutputNaming.FILE_NAME) /** * When enabled, the program communicates with the local server to dynamically reload the requested resources. */ protected val preview: Boolean by option("-p", "--preview", help = "Open or reload content after compiling").flag() /** * When enabled, the program watches for file changes and automatically recompiles the source. * If [preview] is enabled as well, this allows for live reloading. */ protected val watch: Boolean by option("-w", "--watch", help = "Watch for file changes").flag() /** * Port to communicate with the local server on if [preview] is enabled. */ protected val serverPort: Int by option("--server-port", help = "Port to communicate with the local server on") .int() .default(DEFAULT_SERVER_PORT) /** * Path to the Node.js executable, needed for PDF export. */ private val nodePath: String by option("--node-path", help = "Path to the Node.js executable") .default(NodeJsWrapper.defaultPath) /** * Path to the npm executable, needed for PDF export. */ private val npmPath: String by option("--npm-path", help = "Path to the npm executable") .default(NpmWrapper.defaultPath) /** * @return the finalized CLI options based on the command's properties */ fun createCliOptions() = CliOptions( // Might be overridden by a subclass via `finalizeCliOptions`, e.g. `CompileCommand` which requires a source file. source = null, outputDirectory, libraryDirectory, renderer, clean, pipe = false, nodePath, npmPath, ).let(::finalizeCliOptions) /** * @param cliOptions finalized CLI options * @return pipeline options based on the command's properties */ fun createPipelineOptions(cliOptions: CliOptions) = PipelineOptions( resourceName = resourceName, prettyOutput = prettyOutput, wrapOutput = !noWrap, workingDirectory = cliOptions.source?.absoluteFile?.parentFile, enableMediaStorage = !noMediaStorage, subdocumentNaming = subdocumentNaming, serverPort = serverPort.takeIf { preview }, mediaStorageOptionsOverrides = ReadOnlyMediaStorageOptions(), errorHandler = when { strict -> StrictPipelineErrorHandler() else -> BasePipelineErrorHandler() }, ) override fun run() { val cliOptions = this.createCliOptions() val pipelineOptions = this.createPipelineOptions(cliOptions) // If pipe mode is enabled, all logging is disabled, so that only the rendered content is printed to stdout. if (cliOptions.pipe) { Log.disableLogging() } // If file watching is enabled, a file change triggers the pipeline execution again. cliOptions.takeIf { watch }?.source?.absoluteFile?.parentFile?.let { sourceDirectory -> Log.info("Watching for file changes in source directory: $sourceDirectory") DirectoryWatcher .create(sourceDirectory, exclude = cliOptions.outputDirectory) { event -> Log.info("File changed: ${event.path()}. Launching.") execute(cliOptions, pipelineOptions) }.watch() } // Executes the Quarkdown pipeline. execute(cliOptions, pipelineOptions) } /** * Executes the Quarkdown pipeline: compiles and generates output files. * [preExecute] and [postExecute] are called before and after the execution respectively. */ private fun execute( cliOptions: CliOptions, pipelineOptions: PipelineOptions, ) { this.preExecute(cliOptions, pipelineOptions) // Executes the Quarkdown pipeline. val outcome: ExecutionOutcome = runQuarkdown(createExecutionStrategy(cliOptions), cliOptions, pipelineOptions) this.postExecute(outcome, cliOptions, pipelineOptions) } /** * Executes actions before the execution of the pipeline starts. */ protected open fun preExecute( cliOptions: CliOptions, pipelineOptions: PipelineOptions, ) { } /** * Executes actions after the execution of the pipeline has been completed * and the output files have been generated. */ protected open fun postExecute( outcome: ExecutionOutcome, cliOptions: CliOptions, pipelineOptions: PipelineOptions, ) { } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/ExecutionOutcome.kt ================================================ package com.quarkdown.cli.exec import com.quarkdown.core.context.Context import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.output.OutputResource import java.io.File /** * Outcome of a pipeline execution. * @param resource the output resource produced by the pipeline, if any * @param directory the directory, child of the configuration's output directory, where the output artifacts are saved. * If `null`, no output directory was written. * This can happen in case of errors or, more likely, when running in pipe mode (`--pipe`). * @param pipeline the executed pipeline * @see runQuarkdown */ data class ExecutionOutcome( val resource: OutputResource?, val directory: File?, val pipeline: Pipeline, ) { /** * The context of the pipeline. */ val context: Context get() = pipeline.readOnlyContext } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/ReplCommand.kt ================================================ package com.quarkdown.cli.exec import com.quarkdown.cli.CliOptions import com.quarkdown.cli.exec.strategy.ReplExecutionStrategy /** * Command to start Quarkdown in interactive REPL mode. * @see ReplExecutionStrategy */ class ReplCommand : ExecuteCommand("repl") { override fun createExecutionStrategy(cliOptions: CliOptions) = ReplExecutionStrategy() } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/strategy/FileExecutionStrategy.kt ================================================ package com.quarkdown.cli.exec.strategy import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.output.OutputResource import java.io.File /** * A strategy to execute a [Pipeline] from the string content of a file. */ class FileExecutionStrategy( private val file: File, ) : PipelineExecutionStrategy { override fun execute(pipeline: Pipeline): OutputResource? = pipeline.execute(file.readText()) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/strategy/PipelineExecutionStrategy.kt ================================================ package com.quarkdown.cli.exec.strategy import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.output.OutputResource /** * A strategy to execute a [Pipeline]. */ interface PipelineExecutionStrategy { /** * Executes the [pipeline]. * @param pipeline pipeline to execute */ fun execute(pipeline: Pipeline): OutputResource? } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/strategy/ReplExecutionStrategy.kt ================================================ package com.quarkdown.cli.exec.strategy import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.output.OutputResource /** * A strategy to execute a [Pipeline] in a continuous REPL (Read-Eval-Print Loop) mode. * Note that the context is shared across iterations. */ class ReplExecutionStrategy : PipelineExecutionStrategy { override fun execute(pipeline: Pipeline): OutputResource? { Log.info("== Quarkdown REPL ==") Log.info("Type 'exit' to quit.") Log.info("Tip: pass the source file path as an argument to execute it instead.") while (true) { print("\n> ") when (val input = readlnOrNull()) { null, "exit" -> break else -> pipeline.execute(input) } } // No output resources are generated in REPL mode. return null } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/lib/QdLibraries.kt ================================================ package com.quarkdown.cli.lib import com.quarkdown.stdlib.external.QdLibraryExporter import java.io.File private const val EXTENSION_FILTER = "qd" /** * Utilities for handling .qd libraries. */ object QdLibraries { /** * Loads all .qd libraries from a directory. * @param directory directory to load libraries from * @return set of [QdLibraryExporter]s */ fun fromDirectory(directory: File): Set { if (!directory.exists()) throw IllegalArgumentException("Libraries directory does not exist: $directory") if (!directory.isDirectory) throw IllegalArgumentException("Libraries directory is not a directory: $directory") return directory .listFiles()!! .asSequence() .filter { it.extension == EXTENSION_FILTER } .map { QdLibraryExporter(it.nameWithoutExtension) { it.reader() } } .toSet() } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/lsp/LanguageServerCommand.kt ================================================ package com.quarkdown.cli.lsp import com.github.ajalt.clikt.core.CliktCommand import com.quarkdown.cli.util.thisExecutableFile import com.quarkdown.lsp.QuarkdownLanguageServerLauncher /** * Command to start the Quarkdown Language Server. */ class LanguageServerCommand : CliktCommand("language-server") { override fun run() { // The distribution directory which contains lib/, docs/, etc. val quarkdownDirectory = thisExecutableFile?.parentFile?.parentFile QuarkdownLanguageServerLauncher(quarkdownDirectory).startListening() } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/renderer/RendererRetriever.kt ================================================ package com.quarkdown.cli.renderer import com.quarkdown.cli.CliOptions import com.quarkdown.core.context.Context import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.rendering.RenderingComponents import com.quarkdown.rendering.html.extension.html import com.quarkdown.rendering.html.extension.htmlPdf import com.quarkdown.rendering.html.pdf.HtmlPdfExportOptions import com.quarkdown.rendering.plaintext.extension.plainText private const val HTML = "html" private const val HTML_PDF = "html-pdf" private const val PLAIN_TEXT = "text" /** * Given a [CliOptions] instance, retrieves the appropriate renderer (e.g. HTML, PDF) for the pipeline * based on [CliOptions.rendererName] (case-insensitive), [CliOptions.exportPdf] and other options. */ class RendererRetriever( private val options: CliOptions, ) { private val name get() = options.rendererName.lowercase() /** * Retrieves the rendering target specified by [options]. * * Note: the current implementation hardcodes renderer names. In the future an extensible retriever will be implemented. * @return the rendering target for the pipeline, to generate the output for. */ fun getRenderer(): (RendererFactory, Context) -> RenderingComponents = { factory, context -> when { isHtmlPdf() -> factory.htmlPdf(context, createHtmlPdfExportOptions()) isHtml() -> factory.html(context) isPlainText() -> factory.plainText(context) else -> throw IllegalArgumentException("Unsupported renderer: '${options.rendererName}'") } } private fun isHtml() = name == HTML private fun isHtmlPdf() = name == HTML_PDF || (name == HTML && options.exportPdf) private fun isPlainText() = name == PLAIN_TEXT private fun createHtmlPdfExportOptions() = HtmlPdfExportOptions( outputDirectory = requireNotNull(options.outputDirectory) { "Output directory must be specified for PDF export." }, nodeJsPath = options.nodePath, npmPath = options.npmPath, noSandbox = options.noPdfSandbox, ) } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/server/BrowserLauncherOption.kt ================================================ package com.quarkdown.cli.server import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.OptionDelegate import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.option import com.quarkdown.core.log.Log import com.quarkdown.server.browser.BrowserLauncher import com.quarkdown.server.browser.DefaultBrowserLauncher import com.quarkdown.server.browser.EnvBrowserLauncher import com.quarkdown.server.browser.NoneBrowserLauncher import com.quarkdown.server.browser.PathBrowserLauncher import com.quarkdown.server.browser.XdgBrowserLauncher import kotlin.io.path.Path /** * Attempts to create a [BrowserLauncher] from fixed choices: `default`, `xdg`, or `none`. * @param input the input string representing the browser choice * @return the corresponding [BrowserLauncher], if any */ private fun fromFixedChoices(input: String): BrowserLauncher? = when (input) { "default" -> DefaultBrowserLauncher() "none" -> NoneBrowserLauncher() "xdg" -> XdgBrowserLauncher().takeIf { it.isValid } else -> null } /** * Attempts to create a [BrowserLauncher] from environment variables (e.g. `chrome` -> `BROWSER_CHROME`). * @param input the input string representing the browser choice (e.g. `chrome`, `firefox`) * @param envLookup function to look up environment variable values. * If different from `System::getenv`, it can be used for testing purposes * @return the corresponding [BrowserLauncher], if any */ private fun fromEnv( input: String, envLookup: (String) -> String?, ): BrowserLauncher? = EnvBrowserLauncher(input, envLookup) .takeIf { it.isValid } ?.also { Log.info("Using browser launcher $input (env ${it.envName})") } /** * Attempts to create a [BrowserLauncher] from a given file system path. * @param input the input string representing the file system path to the browser executable * @return the corresponding [BrowserLauncher], if any */ private fun fromPath(input: String): BrowserLauncher? = PathBrowserLauncher(Path(input)) .takeIf { it.isValid } ?.also { Log.info("Using browser launcher from path: $input") } /** * Option to select a browser launcher from the CLI, * with validation and support of selection by name, path, or fixed choices. * @param default the default browser launcher to use if no choice is made * @param shouldValidate whether the choice should be validated * @param envLookup function to look up environment variable values. * If different from `System::getenv`, it can be used for testing purposes */ fun CliktCommand.browserLauncherOption( default: BrowserLauncher = NoneBrowserLauncher(), shouldValidate: () -> Boolean = { true }, envLookup: (String) -> String? = System::getenv, ): OptionDelegate = option( "-b", "--browser", help = "Browser to open the served file in (name, path, 'default', 'xdg', 'none')", ).convert { input -> val caseInsensitiveInput = input.lowercase() val launcher = fromFixedChoices(caseInsensitiveInput) ?: fromEnv(caseInsensitiveInput, envLookup) ?: fromPath(input) require(!shouldValidate() || launcher != null) { "The specified browser ($input) cannot be launched " + "because it is either not installed, not loaded in the environment (BROWSER_), " + "not executable, or unsupported." } launcher ?: default }.default(default) ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/server/StartWebServerCommand.kt ================================================ package com.quarkdown.cli.server import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.required import com.github.ajalt.clikt.parameters.types.file import com.github.ajalt.clikt.parameters.types.int import com.quarkdown.server.LocalFileWebServer import com.quarkdown.server.ServerEndpoints import com.quarkdown.server.browser.BrowserLauncher import com.quarkdown.server.message.ServerMessageSession import java.io.File /** * The default port to start the web server on. */ const val DEFAULT_SERVER_PORT = 8089 /** * Command to start a web server serving a local file, * allowing for live reloading. * @see LocalFileWebServer */ class StartWebServerCommand : CliktCommand(name = "start") { /** * File to serve. */ private val targetFile: File by option("-f", "--file", help = "File to serve") .file(mustExist = true, canBeDir = true, canBeFile = true) .required() /** * Port to start the server on. If unset, the default port [DEFAULT_SERVER_PORT] is used. */ private val port: Int by option("-p", "--port", help = "Port to start the server on") .int() .default(DEFAULT_SERVER_PORT) /** * Optional browser to open the served file in. */ private val browser: BrowserLauncher? by browserLauncherOption() override fun run() { val options = WebServerOptions(port, targetFile, browser, preferLivePreviewUrl = true) val session = ServerMessageSession( port = port, endpoint = ServerEndpoints.RELOAD_LIVE_PREVIEW, ) WebServerStarter.start(options, session) } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/server/WebServerOptions.kt ================================================ package com.quarkdown.cli.server import com.quarkdown.server.browser.BrowserLauncher import java.io.File /** * Options for the local web server. * @param port port to start the server on * @param targetFile file to serve * @param browserLauncher strategy to open the served file in the browser. If `null`, the file will not be opened * @param preferLivePreviewUrl if a browser launcher is provided, prefer to open the URL for live preview instead of the static file URL */ data class WebServerOptions( val port: Int, val targetFile: File, val browserLauncher: BrowserLauncher?, val preferLivePreviewUrl: Boolean, ) ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/server/WebServerStarter.kt ================================================ package com.quarkdown.cli.server import com.quarkdown.core.log.Log import com.quarkdown.server.LocalFileWebServer import com.quarkdown.server.ServerEndpoints import com.quarkdown.server.message.ServerMessageSession import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /** * Starter of the web server. */ object WebServerStarter { /** * Starts the web server which serves the specified file and allows for live reloading. * @param options options to start the server with * @param session session to use to communicate with the web server * @param onSessionReady optional callback to invoke when the session is ready */ fun start( options: WebServerOptions, session: ServerMessageSession, onSessionReady: suspend () -> Unit = { }, ) = runBlocking { // Asynchronously start the web server. launch(Dispatchers.IO) { LocalFileWebServer(options.targetFile).start(options.port, wait = false) session.init(onSessionReady) } Log.info("Started web server on port ${options.port}") // Optionally the target file in the browser. options.browserLauncher?.let { try { val endpoint = if (options.preferLivePreviewUrl) ServerEndpoints.LIVE_PREVIEW else ServerEndpoints.ROOT it.launchLocal(options.port, endpoint) } catch (e: Exception) { Log.error("Failed to launch URL via ${it::class.simpleName}: ${e.message}") Log.debug(e) } } } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/util/IOUtils.kt ================================================ package com.quarkdown.cli.util import java.io.File import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.deleteRecursively /** * The executable JAR file location, if available. */ val thisExecutableFile: File? get() = object {} .javaClass.protectionDomain ?.codeSource ?.location ?.toURI() ?.let(::File) /** * Cleans [this] directory by deleting all files and directories inside it. * Does nothing if the directory is empty or if the file does not exist or is not a directory. */ @OptIn(ExperimentalPathApi::class) fun File.cleanDirectory() { listFiles()?.forEach { it.toPath().deleteRecursively() } } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/util/MillisStopwatch.kt ================================================ package com.quarkdown.cli.util /** * Simple immutable stopwatch to measure elapsed time in milliseconds. */ class MillisStopwatch { private val startTime: Long = System.currentTimeMillis() /** * Returns the elapsed time in milliseconds since the creation of this stopwatch. * @return elapsed time in milliseconds */ fun elapsedMillis(): Long = System.currentTimeMillis() - startTime } ================================================ FILE: quarkdown-cli/src/main/kotlin/com/quarkdown/cli/watcher/DirectoryWatcher.kt ================================================ package com.quarkdown.cli.watcher import io.methvin.watcher.DirectoryChangeEvent import java.io.File import java.nio.file.Path import kotlin.concurrent.thread import kotlin.io.path.extension private typealias JDirectoryWatcher = io.methvin.watcher.DirectoryWatcher /** * A [io.methvin.watcher.DirectoryWatcher] wrapper that recursively watches a directory for changes. * @param watcher [io.methvin.watcher.DirectoryWatcher] instance */ class DirectoryWatcher( private val watcher: JDirectoryWatcher, ) { /** * Synchronously starts watching for changes in the directory. */ fun watchBlocking() { watcher.watch() } /** * Asynchronously starts watching for changes in the directory. */ fun watch() { thread(start = true) { watchBlocking() } } /** * Stops watching for changes in the directory. */ fun stop() { watcher.close() } companion object { /** * Creates a new [DirectoryWatcher]. * @param directory directory to watch * @param excludeFiles files or directories to exclude from watching * @param exclude general function to exclude files or directories from watching, for example temporary IDE files * @param onChange function to call when a change is detected */ private fun create( directory: File, excludeFiles: List = emptyList(), exclude: (Path) -> Boolean = { it.extension.endsWith("~") }, onChange: (DirectoryChangeEvent) -> Unit, ) = JDirectoryWatcher .builder() .path(directory.toPath()) .listener { val acceptByPath = !exclude(it.path()) val acceptByFiles = excludeFiles.none { file -> it.path().startsWith(file.absolutePath) } if (acceptByPath && acceptByFiles) { onChange(it) } }.build() .let(::DirectoryWatcher) /** * Creates a new [DirectoryWatcher]. * @param directory directory to watch * @param exclude file or directory to exclude from watching. If `null`, no files are excluded * @param onChange function to call when a change is detected */ fun create( directory: File, exclude: File?, onChange: (DirectoryChangeEvent) -> Unit, ) = create( directory, excludeFiles = exclude?.let(::listOf) ?: emptyList(), onChange = onChange, ) } } ================================================ FILE: quarkdown-cli/src/main/resources/creator/docs/_nav.qd ================================================ ###! First section - [Page 1](page-1.qd) - [Page 2](page-2.qd) ###! Second section - [Page 3](page-3.qd) ================================================ FILE: quarkdown-cli/src/main/resources/creator/docs/main.qd.jte ================================================ @param String name = null @param String initialContent = null @if(name != null) .docname {${name}} @endif .include {docs} @if(initialContent != null) ${initialContent} @endif ================================================ FILE: quarkdown-cli/src/main/resources/creator/docs/page-1.qd ================================================ .docname {Page 1} .include {docs} This is page 1. ================================================ FILE: quarkdown-cli/src/main/resources/creator/docs/page-2.qd ================================================ .docname {Page 2} .include {docs} This is page 2. ## See also You can find more content in [page 3](page-3.qd). ================================================ FILE: quarkdown-cli/src/main/resources/creator/docs/page-3.qd ================================================ .docname {Page 3} .include {docs} This is page 3. ================================================ FILE: quarkdown-cli/src/main/resources/creator/initialcontent.qd.jte ================================================ @param String name = null @param boolean docs = false @param String initialContent = null @param String mainFile = null @if(!docs && name != null) # ${name} @endif Welcome to [Quarkdown](https://github.com/iamgio/quarkdown)! This is the starting point of your document. @if(docs) ## Compiling @endif - To compile this document, please `cd` to this file's parent directory and run: `quarkdown c ${mainFile}.qd` - To enable live preview, run: `quarkdown c ${mainFile}.qd -p -w` @if(docs) ## Wiki @endif For further information and guides, please check out the [official wiki](https://quarkdown.com/wiki). @if(!docs) !(50%)[Quarkdown](image/logo.png) @else ## Structure - `_setup.qd`: global setup, included in each subdocument. - `_nav.qd`: navigation tree, displayed in the sidebar. @endif ================================================ FILE: quarkdown-cli/src/main/resources/creator/main.qd.jte ================================================ @param String name = null @param String description = null @param java.util.List keywords = java.util.Collections.emptyList() @param java.util.List authors = java.util.Collections.emptyList() @param String type = null @param boolean docs = false @param String lang = null @param boolean theme = false @param String colorTheme = null @param String layoutTheme = null @param boolean pageCounter = false @param String initialContent = null @if(!docs && name != null) .docname {${name}} @endif @if(description != null) .docdescription {${description}} @endif @if(!docs && type != null) .doctype {${type}} @endif @if(lang != null) .doclang {${lang}} @endif @if(theme) .theme@if(colorTheme != null) {${colorTheme}}@endif@if(layoutTheme != null) layout:{${layoutTheme}}@endif @endif @if(!keywords.isEmpty()) ${""} .dockeywords @for(String item : keywords) ${" "}- ${item} @endfor @endif @if(!authors.isEmpty()) ${""} .docauthors @for(String item : authors) ${" "}- ${item} @endfor @endif @if(pageCounter) ${""} .pagemargin {bottomcenter} .currentpage @endif @if(docs && name != null) ${""} .pagemargin {topleft} ${name} @endif @if(!docs && initialContent != null) ${""} ${initialContent} @endif ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/BrowserLauncherSelectionTest.kt ================================================ package com.quarkdown.cli import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.testing.test import com.quarkdown.cli.server.browserLauncherOption import com.quarkdown.server.browser.BrowserLauncher import com.quarkdown.server.browser.DefaultBrowserLauncher import com.quarkdown.server.browser.EnvBrowserLauncher import com.quarkdown.server.browser.NoneBrowserLauncher import com.quarkdown.server.browser.XdgBrowserLauncher import kotlin.test.Test import kotlin.test.assertFails import kotlin.test.assertIs /** * Mock command to test browser launcher selection. * Environment variables are simulated via [env]. */ private class MockCommand( env: Map, ) : CliktCommand() { val browserLauncher by browserLauncherOption(envLookup = env::get) override fun run() {} } /** * Tests for browser launcher selection via [browserLauncherOption]. */ class BrowserLauncherSelectionTest { private fun test( value: String? = null, env: Map = emptyMap(), ): BrowserLauncher? = MockCommand(env) .also { val argv = if (value != null) { arrayOf("--browser", value) } else { emptyArray() } it.test(argv) }.browserLauncher @Test fun fallback() { assertIs(test()) } @Test fun `default choice`() { assertIs(test("default")) } @Test fun `none choice`() { assertIs(test("none")) } @Test fun `from env`() { val choice = "chrome" val envName = "BROWSER_${choice.uppercase()}" assertIs(test(choice, env = mapOf(envName to "/path/to/chrome"))) } @Test fun `xdg choice resolves to XdgBrowserLauncher when available`() { val xdgLauncher = XdgBrowserLauncher() if (xdgLauncher.isValid) { assertIs(test("xdg")) } else { // xdg-open is not available on this platform assertFails { test("xdg") } } } @Test fun `invalid from env`() { val choice = "nonexistentbrowser" assertFails { test(choice) } } @Test fun `invalid from path`() { val path = "path/to/nonexistent/browser" assertFails { test(path) } } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/CompileCommandTest.kt ================================================ package com.quarkdown.cli import com.github.ajalt.clikt.testing.CliktCommandTestResult import com.github.ajalt.clikt.testing.test import com.quarkdown.cli.exec.CompileCommand import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.core.pipeline.error.StrictPipelineErrorHandler import com.quarkdown.interaction.Env import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NpmWrapper import com.quarkdown.rendering.html.pdf.PuppeteerNodeModule import org.apache.pdfbox.Loader import org.junit.Assume.assumeTrue import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue private const val DEFAULT_OUTPUT_DIRECTORY_NAME = "Quarkdown-test" /** * Tests for the Quarkdown compile command `c`. */ class CompileCommandTest : TempDirectory() { private val command = CompileCommand() private val main = File(directory, "main.qd") private val outputDirectory = File(directory, "out") private val content = """ .docname {Quarkdown test} .doctype {paged} Page 1 <<< Page 2 <<< Page 3 """.trimIndent() @BeforeTest fun setup() { super.reset() main.writeText(content) } private fun test(vararg additionalArgs: String): Triple { val result = command.test( main.absolutePath, "-o", outputDirectory.absolutePath, *additionalArgs, ) val cliOptions = command.createCliOptions() val pipelineOptions = command.createPipelineOptions(cliOptions) assertEquals(main, cliOptions.source) assertEquals(directory, pipelineOptions.workingDirectory) assertEquals( outputDirectory.takeUnless { cliOptions.pipe }, cliOptions.outputDirectory, ) return Triple(cliOptions, pipelineOptions, result) } private fun assertHtmlContentPresent(directoryName: String = DEFAULT_OUTPUT_DIRECTORY_NAME) { val outputDir = File(outputDirectory, directoryName) assertTrue(outputDir.exists()) assertTrue(outputDir.isDirectory()) outputDir.listFiles()!!.map { it.name }.let { "index.html" in it "script" in it "theme" in it } } private fun subdocumentExists(name: String): Boolean = outputDirectory .resolve(DEFAULT_OUTPUT_DIRECTORY_NAME) .resolve(name) .let { it.exists() && it.isDirectory() } private fun base(explicitRenderer: String? = null) { val (cliOptions, pipelineOptions) = explicitRenderer?.let { test("--render", it) } ?: test() assertHtmlContentPresent() assertFalse(cliOptions.clean) assertFalse(cliOptions.pipe) pipelineOptions.let { assertFalse(it.prettyOutput) assertTrue(it.wrapOutput) assertTrue(it.enableMediaStorage) assertIs(it.errorHandler) } } @Test fun base() = base(null) @Test fun `base with explicit html renderer`() = base("html") @Test fun `explicit output name`() { val (_, pipelineOptions) = test("--out-name", "A new name") assertEquals("A new name", pipelineOptions.resourceName) assertHtmlContentPresent(directoryName = "A-new-name") } @Test fun strict() { val (_, pipelineOptions) = test("--strict") assertHtmlContentPresent() assertIs(pipelineOptions.errorHandler) } @Test fun `pretty, no wrap`() { val (_, pipelineOptions) = test("--pretty", "--nowrap") assertHtmlContentPresent() assertTrue(pipelineOptions.prettyOutput) assertFalse(pipelineOptions.wrapOutput) } @Test fun clean() { val dummyFile = File(outputDirectory, "dummy.txt").apply { parentFile.mkdirs() writeText("This is a dummy file.") } test("--clean") assertHtmlContentPresent() assertFalse(dummyFile.exists()) } @Test fun pipe() { val pipeStdout = java.io.ByteArrayOutputStream() val nonPipeStdout = java.io.ByteArrayOutputStream() val originalOut = System.out try { System.setOut(java.io.PrintStream(pipeStdout)) val (cliOptions, pipelineOptions) = test("--pipe") assertTrue(cliOptions.pipe) assertTrue(pipelineOptions.wrapOutput) val output = pipeStdout.toString() assertTrue(output.contains("")) assertTrue(output.contains("Page 1")) assertTrue(output.contains("Page 2")) assertTrue(output.contains("Page 3")) assertFalse(outputDirectory.exists()) System.setOut(java.io.PrintStream(nonPipeStdout)) test() val outputNonPipe = nonPipeStdout.toString() assertFalse(outputNonPipe.contains("Page 1")) assert(output.length > outputNonPipe.length) } finally { System.setOut(originalOut) } } @Test fun `pipe, no wrap`() { val pipeStdout = java.io.ByteArrayOutputStream() val originalOut = System.out try { System.setOut(java.io.PrintStream(pipeStdout)) val (cliOptions, pipelineOptions) = test("--pipe", "--nowrap") assertTrue(cliOptions.pipe) assertFalse(pipelineOptions.wrapOutput) val output = pipeStdout.toString() assertFalse(output.contains("")) assertTrue(output.contains("Page 1")) assertTrue(output.contains("Page 2")) assertTrue(output.contains("Page 3")) } finally { System.setOut(originalOut) } } private fun setupSubdocuments(): List { main.writeText("$content\n\n[Subdoc 1](subdoc1.qd)\n\n[Subdoc 2](subdoc2.qd)") return listOf( File(directory, "subdoc1.qd").apply { writeText("This is a subdocument.") }, File(directory, "subdoc2.qd").apply { writeText("This is another subdocument. [Subdoc 3](subdoc3.qd)") }, File(directory, "subdoc3.qd").apply { writeText("This is yet another subdocument.") }, ) } @Test fun `with subdocument`() { setupSubdocuments() test() assertHtmlContentPresent() assertTrue(subdocumentExists("subdoc1")) assertTrue(subdocumentExists("subdoc2")) assertTrue(subdocumentExists("subdoc3")) } @Test fun `with subdocument with minimized collisions`() { val (subdoc1, subdoc2, subdoc3) = setupSubdocuments() fun assertSubdocumentExistsWithHash( name: String, file: File, ) { assertTrue( subdocumentExists("$name@${file.absolutePath.hashCode()}") || subdocumentExists("$name@${file.canonicalFile.absolutePath.hashCode()}"), ) } test("--subdoc-naming", "collision-proof") assertHtmlContentPresent() assertSubdocumentExistsWithHash("subdoc1", subdoc1) assertSubdocumentExistsWithHash("subdoc2", subdoc2) assertSubdocumentExistsWithHash("subdoc3", subdoc3) } private fun assumePdfEnvironmentInstalled() { assumeTrue(Env.npmPrefix != null) assumeTrue(Env.nodePath != null) val node = NodeJsWrapper(NodeJsWrapper.defaultPath, workingDirectory = directory) assumeTrue(node.isValid) with(NpmWrapper(NpmWrapper.defaultPath)) { assumeTrue(isValid) assumeTrue(isInstalled(node, PuppeteerNodeModule)) } } private fun checkPdf( name: String = "$DEFAULT_OUTPUT_DIRECTORY_NAME.pdf", expectedPages: Int = 3, ) { val pdf = File(outputDirectory, name) assertTrue(pdf.exists()) assertFalse(File(outputDirectory, DEFAULT_OUTPUT_DIRECTORY_NAME).exists()) Loader.loadPDF(pdf).use { assertEquals(expectedPages, it.numberOfPages) } } @Test fun pdf() { assumePdfEnvironmentInstalled() val (_, _) = test("--pdf", "--pdf-no-sandbox") checkPdf() } @Test fun `pdf with explicit output name`() { assumePdfEnvironmentInstalled() val (_, _) = test("--pdf", "--pdf-no-sandbox", "--out-name", "A new name") checkPdf(name = "A-new-name.pdf") } @Test fun `single-page pdf`() { assumePdfEnvironmentInstalled() main.writeText(main.readText().replace("paged", "plain") + "\n\n.repeat {100}\n\t.loremipsum") val (_, _) = test("--pdf", "--pdf-no-sandbox") checkPdf(expectedPages = 1) } @Test fun `clean pdf`() { assumePdfEnvironmentInstalled() test("--pdf", "--pdf-no-sandbox", "--clean") checkPdf() } @Test fun `pdf with subdocuments`() { assumePdfEnvironmentInstalled() setupSubdocuments() val (_, _) = test("--pdf", "--pdf-no-sandbox") val pdfDir = File(outputDirectory, DEFAULT_OUTPUT_DIRECTORY_NAME) assertTrue(pdfDir.exists()) assertTrue(pdfDir.isDirectory) assertTrue(pdfDir.resolve("subdoc1.pdf").exists()) assertTrue(pdfDir.resolve("subdoc2.pdf").exists()) assertTrue(pdfDir.resolve("subdoc3.pdf").exists()) assertEquals(4, pdfDir.listFiles()!!.size) } // #86 @Test fun `pdf with toc and id starting with digit`() { assumePdfEnvironmentInstalled() main.writeText( """ .docname {Quarkdown test} .doctype {paged} .doclang {en} .tableofcontents # 1 Test """.trimIndent(), ) val (_, _) = test("--pdf", "--pdf-no-sandbox") checkPdf(expectedPages = 2) } @Test fun `pdf via explicit html-pdf`() { assumePdfEnvironmentInstalled() val (_, _) = test("--render", "html-pdf", "--pdf-no-sandbox") checkPdf() } @Test fun `pdf with node and npm set`() { assumePdfEnvironmentInstalled() val (_, _) = test( "--pdf", "--pdf-no-sandbox", "--node-path", NodeJsWrapper.defaultPath, "--npm-path", NpmWrapper.defaultPath, ) checkPdf() } @Test fun `plaintext, single subdocument`() { val (_, _, _) = test("--render", "text") val outputFile = outputDirectory.resolve("$DEFAULT_OUTPUT_DIRECTORY_NAME.txt") assertTrue(outputFile.exists()) val outputContent = outputFile.readText() assertTrue(outputContent.contains("Page 1")) assertTrue(outputContent.contains("Page 2")) assertTrue(outputContent.contains("Page 3")) } @Test fun `plaintext, multiple subdocuments`() { setupSubdocuments() val (_, _, _) = test("--render", "text") val outputDir = outputDirectory.resolve(DEFAULT_OUTPUT_DIRECTORY_NAME) assertTrue(outputDir.exists()) assertTrue(outputDir.isDirectory) val mainOutputFile = outputDir.resolve("index.txt") assertTrue(mainOutputFile.exists()) val mainOutputContent = mainOutputFile.readText() assertTrue(mainOutputContent.contains("Page 1")) assertTrue(mainOutputContent.contains("Page 2")) assertTrue(mainOutputContent.contains("Page 3")) val subdoc1OutputFile = outputDir.resolve("subdoc1.txt") assertTrue(subdoc1OutputFile.exists()) val subdoc1OutputContent = subdoc1OutputFile.readText() assertTrue(subdoc1OutputContent.contains("This is a subdocument.")) val subdoc2OutputFile = outputDir.resolve("subdoc2.txt") assertTrue(subdoc2OutputFile.exists()) val subdoc2OutputContent = subdoc2OutputFile.readText() assertTrue(subdoc2OutputContent.contains("This is another subdocument.")) val subdoc3OutputFile = outputDir.resolve("subdoc3.txt") assertTrue(subdoc3OutputFile.exists()) val subdoc3OutputContent = subdoc3OutputFile.readText() assertTrue(subdoc3OutputContent.contains("This is yet another subdocument.")) } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/ProjectCreatorCommandTest.kt ================================================ package com.quarkdown.cli import com.github.ajalt.clikt.testing.test import com.quarkdown.cli.creator.command.CreateProjectCommand import org.junit.Test import java.io.File import kotlin.test.BeforeTest import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for [CreateProjectCommand] */ class ProjectCreatorCommandTest : TempDirectory() { private val command = CreateProjectCommand() @BeforeTest fun setup() { super.reset() } /** * Runs the command with the given arguments and verifies the created files. * @param additionalArgs additional command line arguments to pass * @param fixedMainFileName whether to use a fixed main file name * @param directory directory to create the project in * @param includeDescription whether to include the description argument * @param includeKeywords whether to include the keywords argument * @return content of the main file */ private fun test( additionalArgs: Array = emptyArray(), fixedMainFileName: Boolean = true, directory: File = super.directory, includeDescription: Boolean = true, includeKeywords: Boolean = true, ): String { // resolve(".") tests the canonical path instead of the actual one. command.test( directory.resolve(".").absolutePath, "--name", "test", "--authors", "Aaa, Bbb,Ccc", "--type", "slides", "--description", if (includeDescription) "A test document for slides" else "", "--lang", "en", "--color-theme", "darko", "--layout-theme", "latex", *additionalArgs, *if (includeKeywords) arrayOf("--keywords", "testing,slides, quarkdown") else emptyArray(), *if (fixedMainFileName) arrayOf("--main-file", "main") else emptyArray(), ) assertTrue(directory.exists()) val mainFileName = "main.qd" assertTrue(mainFileName in directory.listFiles()!!.map { it.name }) val main = directory.listFiles()!!.first { it.name == mainFileName }.readText() assertTrue(main.startsWith(".docname {test}")) if (includeDescription) { assertTrue(".docdescription {A test document for slides}" in main) } if (includeKeywords) { assertTrue(".dockeywords\n - testing\n - slides\n - quarkdown" in main) } assertTrue("- Aaa" in main) assertTrue("- Bbb" in main) assertTrue("- Ccc" in main) assertTrue(".doctype {slides}" in main) assertTrue(".doclang {English}" in main) assertTrue(".theme {darko} layout:{latex}" in main) return main } @Test fun default() { test() assertEquals(2, directory.listFiles()!!.size) } @Test fun `default with relative name`() { test(fixedMainFileName = false) assertEquals(2, directory.listFiles()!!.size) } @Test fun `default in new directory`() { val dir = File(super.directory, "subdir") test(directory = dir) assertEquals(2, dir.listFiles()!!.size) } @Test fun `default empty`() { test(arrayOf("--empty")) assertEquals(1, directory.listFiles()!!.size) } @Test fun `empty description`() { val main = test(includeDescription = false) assertTrue(".docdescription" !in main) } @Test fun `no keywords`() { val main = test(includeKeywords = false) assertTrue(".dockeywords" !in main) } @Test fun docs() { command.test( directory.resolve(".").absolutePath, "--name", "test", "--authors", "", "--type", "docs", "--description", "", "--lang", "", "--main-file", "main", ) assertTrue(directory.exists()) val fileNames = directory.listFiles()!!.map { it.name } // Docs projects create: main.qd, _setup.qd, _nav.qd, page-1.qd, page-2.qd, page-3.qd assertTrue("main.qd" in fileNames) assertTrue("_setup.qd" in fileNames) assertTrue("_nav.qd" in fileNames) assertTrue("page-1.qd" in fileNames) assertTrue("page-2.qd" in fileNames) assertTrue("page-3.qd" in fileNames) val setup = directory.resolve("_setup.qd").readText() assertContains(setup, ".pagemargin {topleft}") assertTrue(".doctype" !in setup) // .include {docs} is used on each page instead. val main = directory.resolve("main.qd").readText() assertContains(main, ".docname {test}") assertContains(main, ".include {docs}") // Each page includes the `docs` library. for (i in 1..3) { assertContains(directory.resolve("page-$i.qd").readText(), ".include {docs}") } } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/ProjectCreatorTest.kt ================================================ package com.quarkdown.cli import com.quarkdown.cli.creator.ProjectCreator import com.quarkdown.cli.creator.content.DefaultProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.content.DocsProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.content.EmptyProjectCreatorInitialContentSupplier import com.quarkdown.cli.creator.template.DefaultProjectCreatorTemplateProcessorFactory import com.quarkdown.cli.creator.template.DocsProjectCreatorTemplateProcessorFactory import com.quarkdown.core.document.DocumentAuthor import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.document.DocumentType import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue /** * Tests for [ProjectCreator]. */ class ProjectCreatorTest { private val OutputResource.textContent get() = (this as TextOutputArtifact).content private fun projectCreator( info: DocumentInfo, includeInitialContent: Boolean = false, ) = ProjectCreator( DefaultProjectCreatorTemplateProcessorFactory(info), if (includeInitialContent) DefaultProjectCreatorInitialContentSupplier() else EmptyProjectCreatorInitialContentSupplier(), mainFileName = "main", ) private fun docsProjectCreator( info: DocumentInfo, includeInitialContent: Boolean = false, ) = ProjectCreator( DocsProjectCreatorTemplateProcessorFactory(info), if (includeInitialContent) DocsProjectCreatorInitialContentSupplier() else EmptyProjectCreatorInitialContentSupplier(), mainFileName = "main", ) @Test fun empty() { val creator = projectCreator(DocumentInfo()) val resources = creator.createResources() assertEquals(1, resources.size) with(resources.first()) { assertEquals("main", name) assertIs(this) assertEquals(".doctype {plain}", textContent) } } @Test fun `only name`() { val creator = projectCreator(DocumentInfo(name = "Test")) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docname {Test}\n.doctype {plain}", resources.first().textContent) } @Test fun `only description`() { val creator = projectCreator(DocumentInfo(description = "A sample document")) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docdescription {A sample document}\n.doctype {plain}", resources.first().textContent) } @Test fun `name and description`() { val creator = projectCreator(DocumentInfo(name = "Test", description = "A test document")) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docname {Test}\n.docdescription {A test document}\n.doctype {plain}", resources.first().textContent) } @Test fun `only keywords`() { val creator = projectCreator(DocumentInfo(keywords = listOf("kotlin", "testing"))) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals( ".doctype {plain}\n\n.dockeywords\n - kotlin\n - testing", resources.first().textContent, ) } @Test fun `name and keywords`() { val creator = projectCreator(DocumentInfo(name = "Test", keywords = listOf("kotlin", "documentation"))) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals( ".docname {Test}\n.doctype {plain}\n\n.dockeywords\n - kotlin\n - documentation", resources.first().textContent, ) } private val singleAuthor: MutableList get() = mutableListOf(DocumentAuthor("Giorgio")) @Test fun `only author`() { val creator = projectCreator(DocumentInfo(authors = singleAuthor)) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n\n.docauthors\n - Giorgio", resources.first().textContent) } @Test fun `name and author`() { val creator = projectCreator(DocumentInfo(name = "Document", authors = singleAuthor)) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docname {Document}\n.doctype {plain}\n\n.docauthors\n - Giorgio", resources.first().textContent) } @Test fun `description and author`() { val creator = projectCreator(DocumentInfo(description = "Test description", authors = singleAuthor)) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docdescription {Test description}\n.doctype {plain}\n\n.docauthors\n - Giorgio", resources.first().textContent) } @Test fun `multiple authors`() { val creator = projectCreator(DocumentInfo(authors = mutableListOf(DocumentAuthor("Giorgio"), DocumentAuthor("John")))) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n\n.docauthors\n - Giorgio\n - John", resources.first().textContent) } @Test fun `name and slides type`() { val creator = projectCreator(DocumentInfo(name = "Document", type = DocumentType.SLIDES)) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".docname {Document}\n.doctype {slides}", resources.first().textContent) } @Test fun `name and paged type`() { val creator = projectCreator(DocumentInfo(name = "Document", type = DocumentType.PAGED)) val resources = creator.createResources() assertEquals(1, resources.size) resources.first().textContent.let { assertContains(it, ".doctype {paged}") assertContains(it, ".pagemargin {bottomcenter}") } } @Test fun `only language`() { val creator = projectCreator(DocumentInfo(locale = LocaleLoader.SYSTEM.find("it")!!)) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n.doclang {Italian}", resources.first().textContent) } @Test fun `full theme`() { val creator = projectCreator(DocumentInfo(theme = DocumentTheme(color = "dark", layout = "minimal"))) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n.theme {dark} layout:{minimal}", resources.first().textContent) } @Test fun `only color theme`() { val creator = projectCreator(DocumentInfo(theme = DocumentTheme(color = "dark", layout = null))) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n.theme {dark}", resources.first().textContent) } @Test fun `only layout theme`() { val creator = projectCreator( DocumentInfo(theme = DocumentTheme(color = null, layout = "latex")), ) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals(".doctype {plain}\n.theme layout:{latex}", resources.first().textContent) } @Test fun `locale, theme and author`() { val creator = projectCreator( DocumentInfo( locale = LocaleLoader.SYSTEM.find("en")!!, theme = DocumentTheme(color = "dark", layout = "minimal"), authors = singleAuthor, ), ) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals( """ .doctype {plain} .doclang {English} .theme {dark} layout:{minimal} .docauthors - Giorgio """.trimIndent(), resources.first().textContent, ) } @Test fun `name, description, keywords, locale, theme and author`() { val creator = projectCreator( DocumentInfo( name = "Comprehensive Test", description = "A comprehensive test document", keywords = listOf("test", "kotlin", "quarkdown"), locale = LocaleLoader.SYSTEM.find("en")!!, theme = DocumentTheme(color = "dark", layout = "minimal"), authors = singleAuthor, ), ) val resources = creator.createResources() assertEquals(1, resources.size) assertEquals( """ .docname {Comprehensive Test} .docdescription {A comprehensive test document} .doctype {plain} .doclang {English} .theme {dark} layout:{minimal} .dockeywords - test - kotlin - quarkdown .docauthors - Giorgio """.trimIndent(), resources.first().textContent, ) } @Test fun `initial content`() { val creator = projectCreator(DocumentInfo(name = "Document"), includeInitialContent = true) val resources = creator.createResources() assertEquals(2, resources.size) val source = resources.first { it is TextOutputArtifact } val groups = resources.filterIsInstance() assertEquals(1, groups.size) val images = groups.first { it.name == "image" } assertEquals("logo.png", images.resources.single().name) assertTrue( source.textContent.startsWith( """ .docname {Document} .doctype {plain} # Document """.trimIndent(), ), ) assertTrue("quarkdown c main.qd" in source.textContent) assertFalse("## Compiling" in source.textContent) } @Test fun `docs with name`() { val creator = docsProjectCreator(DocumentInfo(name = "Test", type = DocumentType.DOCS)) val resources = creator.createResources() assertEquals(2, resources.size) val setup = resources.first { it.name == "_setup" } val main = resources.first { it.name == "main" } // _setup: name and doctype are excluded for docs. assertTrue(".docname" !in setup.textContent) assertTrue(".doctype" !in setup.textContent) // _setup: has page margin with name. assertContains(setup.textContent, ".pagemargin {topleft}") assertContains(setup.textContent, "Test") // main: has .docname and .include {docs}. assertContains(main.textContent, ".docname {Test}") assertContains(main.textContent, ".include {docs}") } @Test fun `docs with name and description`() { val creator = docsProjectCreator( DocumentInfo(name = "Test", description = "A test document", type = DocumentType.DOCS), ) val resources = creator.createResources() assertEquals(2, resources.size) val setup = resources.first { it.name == "_setup" } val main = resources.first { it.name == "main" } // _setup: has description and page margin, but not .docname or .doctype. assertContains(setup.textContent, ".docdescription {A test document}") assertContains(setup.textContent, ".pagemargin {topleft}") assertTrue(".docname" !in setup.textContent) assertTrue(".doctype" !in setup.textContent) // main: has .docname and .include {docs}. assertContains(main.textContent, ".docname {Test}") assertContains(main.textContent, ".include {docs}") } @Test fun `docs with name, author and keywords`() { val creator = docsProjectCreator( DocumentInfo( name = "Test", type = DocumentType.DOCS, authors = singleAuthor, keywords = listOf("kotlin", "docs"), ), ) val resources = creator.createResources() assertEquals(2, resources.size) val setup = resources.first { it.name == "_setup" } // _setup: has authors, keywords, and page margin. assertContains(setup.textContent, ".docauthors") assertContains(setup.textContent, "- Giorgio") assertContains(setup.textContent, ".dockeywords") assertContains(setup.textContent, "- kotlin") assertContains(setup.textContent, "- docs") assertContains(setup.textContent, ".pagemargin {topleft}") // _setup: no .docname or .doctype for docs. assertTrue(".docname" !in setup.textContent) assertTrue(".doctype" !in setup.textContent) } @Test fun `docs with initial content`() { val creator = docsProjectCreator( DocumentInfo(name = "Test", type = DocumentType.DOCS), includeInitialContent = true, ) val resources = creator.createResources() // 2 text outputs (_setup, main) + 4 docs resource files. assertEquals(6, resources.size) val main = resources.first { it.name == "main" } assertContains(main.textContent, ".docname {Test}") assertContains(main.textContent, ".include {docs}") // Initial content for docs has specific sections. assertContains(main.textContent, "\n## Compiling\n") assertContains(main.textContent, "quarkdown c main.qd") assertContains(main.textContent, "\n## Structure\n") assertContains(main.textContent, "_setup.qd") // Initial content for docs does not have a heading or an image. assertTrue("# Test" !in main.textContent) assertTrue("logo.png" !in main.textContent) // The docs resource files. assertTrue(resources.any { it.name == "_nav.qd" }) assertTrue(resources.any { it.name == "page-1.qd" }) assertTrue(resources.any { it.name == "page-2.qd" }) assertTrue(resources.any { it.name == "page-3.qd" }) } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/TempDirectory.kt ================================================ package com.quarkdown.cli import java.io.File import kotlin.io.path.createTempDirectory /** * Base class for tests that require a temporary directory. */ open class TempDirectory { protected val directory: File = createTempDirectory() .toFile() protected fun reset() { directory.deleteRecursively() directory.mkdirs() } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/VersionTest.kt ================================================ package com.quarkdown.cli import com.github.ajalt.clikt.testing.test import kotlin.test.Test import kotlin.test.assertTrue /** * Tests for the `--version` option of the CLI. */ class VersionTest { @Test fun `version echo`() { val output = QuarkdownCommand().test("--version").output assertTrue(Regex("quarkdown version \\d+\\.\\d+\\.\\d+").containsMatchIn(output)) } } ================================================ FILE: quarkdown-cli/src/test/kotlin/com/quarkdown/cli/WatcherTest.kt ================================================ package com.quarkdown.cli import com.quarkdown.cli.watcher.DirectoryWatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** * Tests for directory watching. * @see DirectoryWatcher */ class WatcherTest : TempDirectory() { private val file = File(directory, "file.txt") @BeforeTest fun setup() { super.reset() } /** * Watches the directory and performs an action that should trigger a change. * @param affect action that should trigger a change */ private fun watch( exclude: File? = null, affect: () -> Unit, ) { file.createNewFile() var changed = false runBlocking { val watcher = DirectoryWatcher.create(directory, exclude) { changed = true } launch(Dispatchers.IO) { watcher.watchBlocking() } launch { delay(1000) affect() delay(500) watcher.stop() delay(400) assertTrue(changed) } } } @Test fun `file change`() = watch { file.writeText("Hello, world!") } @Test fun `file creation`() = watch { File(directory, "new-file.txt").createNewFile() } @Test fun `file deletion`() = watch { file.delete() } @Test fun exclude() { assertFailsWith { watch(exclude = file) { file.writeText("Hello, world!") } } } } ================================================ FILE: quarkdown-core/build.gradle.kts ================================================ plugins { kotlin("jvm") id("com.quarkdown.amber") version "2.1.4" `java-test-fixtures` } val cslStyles: Configuration by configurations.creating dependencies { sequenceOf(kotlin("test"), "org.assertj:assertj-core:3.27.6").forEach { testFixturesImplementation(it) testImplementation(it) } testImplementation(testFixtures(project)) implementation(kotlin("reflect")) implementation("com.github.h0tk3y.betterParse:better-parse:0.4.4") implementation("org.apache.logging.log4j:log4j-core:2.25.3") implementation("org.apache.commons:commons-text:1.15.0") implementation("gg.jte:jte:3.2.3") implementation("com.github.ajalt.colormath:colormath:3.6.1") implementation("com.github.fracpete:romannumerals4j:0.0.1") implementation("de.undercouch:citeproc-java:3.5.0") cslStyles("org.citationstyles:styles:26.2") implementation("org.citationstyles:locales:26.2") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") } // Extracts only the CSL style files listed in csl-styles.txt from the full styles collection, to reduce the bundle size. val extractCslStyles by tasks.registering { val styleListFile = file("csl-styles.txt") val outputDir = layout.buildDirectory.dir("generated/csl-styles") inputs.files(cslStyles) inputs.file(styleListFile) outputs.dir(outputDir) doLast { val outDir = outputDir.get().asFile outDir.deleteRecursively() outDir.mkdirs() val styleNames = styleListFile .readLines() .map { it.trim() } .filter { it.isNotBlank() } .toSet() project.copy { from(project.zipTree(cslStyles.singleFile)) into(outDir) include(styleNames.map { "$it.csl" }) } // Verify all listed styles were found. val extracted = outDir.listFiles()?.map { it.nameWithoutExtension }?.toSet() ?: emptySet() val missing = styleNames - extracted if (missing.isNotEmpty()) { error("CSL styles not found in styles JAR: ${missing.joinToString()}") } } } sourceSets.main { resources.srcDir(extractCslStyles) } ================================================ FILE: quarkdown-core/csl-styles.txt ================================================ american-anthropological-association american-chemical-society american-geophysical-union american-institute-of-aeronautics-and-astronautics american-institute-of-physics american-medical-association american-meteorological-society american-physics-society american-physiological-society american-political-science-association american-society-for-microbiology american-society-of-civil-engineers american-society-of-mechanical-engineers american-sociological-association angewandte-chemie annual-reviews annual-reviews-author-date apa associacao-brasileira-de-normas-tecnicas association-for-computing-machinery biomed-central bmj bristol-university-press cell chicago-author-date chicago-notes-bibliography chicago-notes chicago-shortened-notes-bibliography copernicus-publications current-opinion deutsche-gesellschaft-fur-psychologie deutsche-sprache elsevier-harvard elsevier-vancouver elsevier-with-titles frontiers future-medicine future-science-group gost-r-7-0-5-2008-numeric harvard-cite-them-right ieee institute-of-physics-numeric karger-journals mary-ann-liebert-vancouver modern-language-association multidisciplinary-digital-publishing-institute nature pensoft-journals plos royal-society-of-chemistry sage-vancouver sist02 spie-journals springer-basic-author-date springer-basic-brackets springer-fachzeitschriften-medizin-psychologie springer-humanities-author-date springer-lecture-notes-in-computer-science springer-mathphys-brackets springer-socpsych-author-date springer-vancouver taylor-and-francis-chicago-author-date taylor-and-francis-national-library-of-medicine the-institution-of-engineering-and-technology the-lancet thieme-german trends-journals ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ExitCodes.kt ================================================ package com.quarkdown.core /** * Exit code when a Quarkdown function was invoked by an incompatible call. * @see com.quarkdown.core.function.error.InvalidFunctionCallException */ const val BAD_FUNCTION_CALL_EXIT_CODE = 66 /** * Exit code when a Quarkdown function can't be resolved. * @see com.quarkdown.core.function.error.UnresolvedReferenceException */ const val UNRESOLVED_REFERENCE_EXIT_CODE = 67 /** * Exit code when a dynamic value cannot be converted to a static type via [com.quarkdown.core.function.value.factory.ValueFactory]. * @see com.quarkdown.core.function.value.factory.IllegalRawValueException */ const val ILLEGAL_TYPE_CONVERSION_EXIT_CODE = 68 /** * Exit code when an element (e.g. an enum value from a Quarkdown function argument) * does not exist in a look-up table. * @see com.quarkdown.core.function.error.NoSuchElementException */ const val NO_SUCH_ELEMENT_EXIT_CODE = 69 /** * Exit code when a I/O error occurs. * @see com.quarkdown.core.pipeline.error.IOPipelineException */ const val IO_ERROR_EXIT_CODE = 70 /** * Exit code when a runtime error occurs. */ const val RUNTIME_ERROR_EXIT_CODE = 71 ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/AstRoot.kt ================================================ package com.quarkdown.core.ast import com.quarkdown.core.visitor.node.NodeVisitor /** * The root of a node tree. */ class AstRoot( override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } typealias Document = AstRoot ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/InlineContent.kt ================================================ package com.quarkdown.core.ast /** * Represents an ordered sequence of inline nodes, such as text, links, images, etc., * that can be part of a block's text, such as a paragraph or heading. */ typealias InlineContent = List ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/MarkdownContent.kt ================================================ package com.quarkdown.core.ast import com.quarkdown.core.visitor.node.NodeVisitor // Utility nodes that are used as input in Quarkdown functions to expect Markdown data as an argument. /** * A generic group of block nodes used as input for Quarkdown functions. * @see com.quarkdown.core.function.value.factory.ValueFactory.blockMarkdown */ class MarkdownContent( override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(AstRoot(children)) } /** * A generic group of inline nodes used as input for Quarkdown functions. * @see com.quarkdown.core.function.value.factory.ValueFactory.inlineMarkdown */ class InlineMarkdownContent( override val children: InlineContent, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(AstRoot(children)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/Node.kt ================================================ package com.quarkdown.core.ast import com.quarkdown.core.visitor.node.NodeVisitor /** * A node of the abstract syntax tree - can be either a block or an inline element. */ interface Node { /** * Accepts a visitor. * @param T output type of the visitor * @return output of the visit operation */ fun accept(visitor: NodeVisitor): T } /** * A node that may contain a variable number of nested nodes as children. */ interface NestableNode : Node { val children: List } /** * A node that contains a single child node. * @param T type of the child node */ interface SingleChildNestableNode : NestableNode { /** * The single child node. */ val child: T /** * A singleton list containing [child]. */ override val children: List get() = listOf(child) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/AstAttributes.kt ================================================ package com.quarkdown.core.ast.attributes import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.property.AssociatedProperties import com.quarkdown.core.property.MutableAssociatedProperties import com.quarkdown.core.property.MutablePropertyContainer import com.quarkdown.core.property.PropertyContainer /** * Additional information about the node tree, produced by the parsing stage and stored in a [com.quarkdown.core.context.Context]. * @see com.quarkdown.core.context.Context */ interface AstAttributes { /** * The root node of the tree. */ val root: NestableNode? /** * Properties associated with nodes in the AST. * These properties enrich the AST by storing additional information about the nodes, such as: * - [com.quarkdown.core.ast.attributes.location.SectionLocationProperty] for tracking the location of [LocationTrackableNode]s; * - [com.quarkdown.core.ast.attributes.location.LocationLabelProperty] for storing formatted labels of [LocationTrackableNode]s; * - [com.quarkdown.core.ast.media.StoredMediaProperty] for attaching [com.quarkdown.core.media.Media] references. */ val properties: AssociatedProperties /** * @see AssociatedProperties.of on [properties] */ fun of(node: Node): PropertyContainer = properties.of(node) /** * Properties associated with third-party elements in the AST. * These properties are used to track the presence of third-party elements in the AST, * in order to conditionally load third-party libraries in the final artifact * to avoid unnecessary bloat and improve performance. * * These properties are updated during the tree traversal stage of the pipeline. * @see com.quarkdown.core.ast.attributes.presence for properties * @see com.quarkdown.core.context.hooks.presence for hooks that scan the AST and set these properties */ val thirdPartyPresenceProperties: PropertyContainer /** * The function calls to be later executed. */ val functionCalls: List /** * The table of contents of all the headings in the document. * This is generated by the tree traversal stage of the pipeline. */ val tableOfContents: TableOfContents? /** * @return a new copied mutable instance of these attributes */ fun toMutable(): MutableAstAttributes } /** * Writeable attributes that are modified during the parsing process, * and carry useful information for the next stages of the pipeline. * Storing these attributes while parsing prevents a further visit of the final tree. * @param root the root node of the tree. According to the architecture, this is set right after the parsing stage * @param functionCalls the function calls to be later executed * @param hasCode whether there is at least one code block. * @param hasMath whether there is at least one math block or inline. * @see com.quarkdown.core.context.MutableContext */ data class MutableAstAttributes( override var root: NestableNode? = null, override val properties: MutableAssociatedProperties = MutableAssociatedProperties(), override val thirdPartyPresenceProperties: MutablePropertyContainer = MutablePropertyContainer(), override val functionCalls: MutableList = mutableListOf(), override var tableOfContents: TableOfContents? = null, ) : AstAttributes { override fun of(node: Node): MutablePropertyContainer = properties.of(node) override fun toMutable(): MutableAstAttributes = this.copy() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/id/Identifiable.kt ================================================ package com.quarkdown.core.ast.attributes.id /** * An element that can be identified by a unique identifier, referenced and located by other elements in a document. */ interface Identifiable { /** * Accepts an [IdentifierProvider] to generate an identifier for this element. * @param visitor visitor to accept * @param T output type of the provider */ fun accept(visitor: IdentifierProvider): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/id/IdentifierProvider.kt ================================================ package com.quarkdown.core.ast.attributes.id import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading /** * Provides identifiers for [Identifiable] elements. * Usually, an implementation is provided for each rendering target. * For example, HTML identifiers are URI-like. * @param T output type of the identifiers * @see com.quarkdown.core.rendering.html.HtmlIdentifierProvider */ interface IdentifierProvider { fun visit(heading: Heading): T fun visit(footnote: FootnoteDefinition): T } /** * Gets the identifier of an [Identifiable] element. * @param identifiable element to get the identifier of * @return identifier of the element provided by [this] provider */ fun IdentifierProvider.getId(identifiable: Identifiable) = identifiable.accept(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/link/ResolvedLinkUrlProperty.kt ================================================ package com.quarkdown.core.ast.attributes.link import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.property.Property /** * [Property], assigned to each image link, that points to a local relative URL (path) that is different from the original. * For instance, an image may have a link to `images/picture.png`, * but if it's loaded from an included document with a different base path, it may be resolved to, for example, `../images/picture.png`. * * This property assumes paths are stored in a normalized format (i.e., no `./` or `../` segments), * and using `/` as the path separator. * * @see com.quarkdown.core.ast.base.inline.Link * @see com.quarkdown.core.context.hooks.LinkUrlResolverHook for the storing stage */ data class ResolvedLinkUrlProperty( override val value: String, ) : Property { companion object : Property.Key override val key = ResolvedLinkUrlProperty } /** * @param context context where resolution data is stored * @return the resolved URL of this node within the document handled by [context], * or the regular URL if a resolved one is not registered */ fun LinkNode.getResolvedUrl(context: Context): String = context.attributes.of(this)[ResolvedLinkUrlProperty] ?: this.url /** * Registers the resolved path of this node within the document handled by [context]. * @param context context where resolution data is stored * @param resolvedUrl resolved URL to set * @see com.quarkdown.core.context.hooks.LinkUrlResolverHook */ fun LinkNode.setResolvedUrl( context: MutableContext, resolvedUrl: String, ) { context.attributes.of(this) += ResolvedLinkUrlProperty(resolvedUrl) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/localization/LocalizedKind.kt ================================================ package com.quarkdown.core.ast.attributes.localization /** * A node whose kind, e.g. "figure", "table", can be localized to the document language. */ interface LocalizedKind { /** * Key for localization of the kind of this node, * used to look up localized strings in the default [com.quarkdown.core.localization.LocalizationTable]. * @see LocalizedKindKeys */ val kindLocalizationKey: String } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/localization/LocalizedKindKeys.kt ================================================ package com.quarkdown.core.ast.attributes.localization /** * Keys for localization of kinds of nodes, * used to look up localized strings in the default [com.quarkdown.core.localization.LocalizationTable]. * @see LocalizedKind */ object LocalizedKindKeys { /** * @see com.quarkdown.core.ast.base.block.Code */ const val CODE_BLOCK = "listing" /** * @see com.quarkdown.core.ast.quarkdown.block.Figure */ const val FIGURE = "figure" /** * @see com.quarkdown.core.ast.base.block.Heading */ const val HEADING = "section" /** * @see com.quarkdown.core.ast.base.block.Table */ const val TABLE = "table" } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/location/LocationLabelProperty.kt ================================================ package com.quarkdown.core.ast.attributes.location import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.property.Property /** * [Property] that is assigned to each [LocationTrackableNode] with an associated [NumberingFormat]. * Labels are assigned based on each node's location, formatted via its corresponding numbering format. * The labels are often displayed in a caption. * * Examples of these nodes are figures and tables. For instance, depending on the document's [NumberingFormat], * an element may be labeled as `1.1`, `1.2`, `1.3`, `2.1`, etc. * @param value the formatted label * @see com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook for the storing stage */ data class LocationLabelProperty( override val value: String, ) : Property { companion object : Property.Key override val key = LocationLabelProperty } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/location/LocationTrackableNode.kt ================================================ package com.quarkdown.core.ast.attributes.location import com.quarkdown.core.ast.Node import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat /** * A node that requests its location to be tracked within the document's hierarchy. * By location, it is meant the section indices ([SectionLocation]) the node is located in. * @see SectionLocation */ interface LocationTrackableNode : Node { /** * Whether this node should be tracked in the document's hierarchy. */ val canTrackLocation: Boolean get() = true } /** * @param context context where location data is stored * @return the location of this node within the document handled by [context], * or `null` if the location for [this] node is not registered */ fun LocationTrackableNode.getLocation(context: Context): SectionLocation? = context.attributes.of(this)[SectionLocationProperty] /** * Registers the location of this node within the document handled by [context]. * @param context context where location data is stored * @param location location to set * @see com.quarkdown.core.context.hooks.location.LocationAwarenessHook */ fun LocationTrackableNode.setLocation( context: MutableContext, location: SectionLocation, ) { context.attributes.of(this) += SectionLocationProperty(location) } /** * @param context context where location data is stored * @return the location of this node within the document handled by [context], * formatted according to its corresponding [NumberingFormat] via [formatLocation]. * Returns `null` if the location for [this] node is not registered or if it does not have a corresponding [NumberingFormat] rule */ fun LocationTrackableNode.getLocationLabel(context: Context): String? = context.attributes.of(this)[LocationLabelProperty] /** * Registers the formatted location of this node within the document handled by [context], * according to [this] node's [NumberingFormat]. * @param context context where location data is stored * @param label formatted location to set * @see com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook */ fun LocationTrackableNode.setLocationLabel( context: MutableContext, label: String, ) { context.attributes.of(this) += LocationLabelProperty(label) } /** * @return the location of this node within the document handled by [context], * formatted according to the document's numbering format. * Returns `null` if the location for [this] node is not registered, * or if the document does not have a numbering format * @param context context where location data is stored * @param format numbering format to apply in order to stringify the location * @see getLocation * @see NumberingFormat * @see com.quarkdown.core.document.DocumentInfo.numberingOrDefault */ fun LocationTrackableNode.formatLocation( context: Context, format: (DocumentNumbering) -> NumberingFormat?, ): String? = this.getLocation(context)?.let { context.documentInfo.numberingOrDefault ?.let(format) ?.format(it, allowMismatchingLength = false) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/location/SectionLocation.kt ================================================ package com.quarkdown.core.ast.attributes.location /** * The location of a node within the document, in terms of section indices. * Example: * ```markdown * # A * ## A.A * # B * ## B.A * Node <-- location: B.A, represented by the levels [2, 1] * ``` * @param levels section indices */ data class SectionLocation( val levels: List, ) { /** * The depth of this location, i.e., the number of levels it contains. * Example: the location `[1, 1]` has a depth of `2`. * * This is related to [com.quarkdown.core.document.numbering.NumberingFormat.accuracy] * in order to determine whether a location can be used as a label. */ val depth: Int get() = levels.size } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/location/SectionLocationProperty.kt ================================================ package com.quarkdown.core.ast.attributes.location import com.quarkdown.core.property.Property /** * [Property] that is assigned to each node that requests its location to be tracked ([LocationTrackableNode]). * It contains the node's location in the document, in terms of section indices. * @see SectionLocation * @see com.quarkdown.core.context.hooks.location.LocationAwarenessHook for the storing stage */ data class SectionLocationProperty( override val value: SectionLocation, ) : Property { companion object : Property.Key override val key = SectionLocationProperty } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/presence/CodePresenceProperty.kt ================================================ package com.quarkdown.core.ast.attributes.presence import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.property.Property /** * If this property is present in [com.quarkdown.core.ast.attributes.AstAttributes.thirdPartyPresenceProperties] * and its [value] is true, it means there is at least one code block in the AST. * This is used to load the HighlightJS library in HTML rendering only if necessary. * @see com.quarkdown.core.context.hooks.presence.CodePresenceHook */ data class CodePresenceProperty( override val value: Boolean, ) : Property { companion object : Property.Key override val key: Property.Key = CodePresenceProperty } /** * Whether there is at least one code block in the AST. * @see CodePresenceProperty */ val AstAttributes.hasCode: Boolean get() = hasThirdParty(CodePresenceProperty) /** * Marks the presence of code blocks in the AST * if at least one [Code] block is present in the document. * @see CodePresenceProperty * @see com.quarkdown.core.context.hooks.presence.CodePresenceHook */ fun MutableAstAttributes.markCodePresence() = markThirdPartyPresence(CodePresenceProperty(true)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/presence/MathPresenceProperty.kt ================================================ package com.quarkdown.core.ast.attributes.presence import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.property.Property /** * If this property is present in [com.quarkdown.core.ast.attributes.AstAttributes.thirdPartyPresenceProperties] * and its [value] is true, it means there is at least one math block or inline in the AST. * This is used to load the KaTeX library in HTML rendering only if necessary. * @see com.quarkdown.core.context.hooks.presence.MathPresenceHook */ data class MathPresenceProperty( override val value: Boolean, ) : Property { companion object : Property.Key override val key: Property.Key = MathPresenceProperty } /** * Whether there is at least one math block or inline in the AST. * @see MathPresenceProperty */ val AstAttributes.hasMath: Boolean get() = hasThirdParty(MathPresenceProperty) /** * Marks the presence of math blocks or inlines in the AST * if at least one math element is present in the document. * @see MathPresenceProperty * @see com.quarkdown.core.context.hooks.presence.MathPresenceHook */ fun MutableAstAttributes.markMathPresence() = markThirdPartyPresence(MathPresenceProperty(true)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/presence/MermaidPresenceProperty.kt ================================================ package com.quarkdown.core.ast.attributes.presence import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.property.Property /** * If this property is present in [com.quarkdown.core.ast.attributes.AstAttributes.thirdPartyPresenceProperties] * and its [value] is true, it means there is at least one Mermaid diagram in the AST. * This is used to load the Mermaid library in HTML rendering only if necessary. * @see com.quarkdown.core.context.hooks.presence.MermaidDiagramPresenceHook */ data class MermaidDiagramPresenceProperty( override val value: Boolean, ) : Property { companion object : Property.Key override val key: Property.Key = MermaidDiagramPresenceProperty } /** * Whether there is at least one Mermaid diagram in the AST. * @see MermaidDiagramPresenceProperty */ val AstAttributes.hasMermaidDiagram: Boolean get() = hasThirdParty(MermaidDiagramPresenceProperty) /** * Marks the presence of Mermaid diagrams in the AST * if at least one diagram is present in the document. * @see MermaidDiagramPresenceProperty * @see com.quarkdown.core.context.hooks.presence.MermaidDiagramPresenceHook */ fun MutableAstAttributes.markMermaidDiagramPresence() = markThirdPartyPresence(MermaidDiagramPresenceProperty(true)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/presence/ThirdPartyPresenceProperties.kt ================================================ package com.quarkdown.core.ast.attributes.presence import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.property.Property /** * @return whether the [AstAttributes] contain a third-party presence property with the given [key] */ internal fun AstAttributes.hasThirdParty(key: Property.Key): Boolean = thirdPartyPresenceProperties[key] == true /** * Marks the presence of a third-party element in the AST via the given [property]. */ internal fun MutableAstAttributes.markThirdPartyPresence(property: Property) { thirdPartyPresenceProperties += property } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/reference/ReferenceNode.kt ================================================ package com.quarkdown.core.ast.attributes.reference import com.quarkdown.core.ast.Node import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext /** Represents a node that may reference some definition that is generated by elsewhere in the document. * * Examples: * - [com.quarkdown.core.ast.base.inline.ReferenceLink] refers to a [com.quarkdown.core.ast.base.LinkNode] * - [com.quarkdown.core.ast.base.inline.ReferenceFootnote] refers to a [com.quarkdown.core.ast.base.block.FootnoteDefinition] * - [com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation] refers to a [com.quarkdown.core.bibliography.BibliographyEntry] * * @param R the type of the reference element * @param D the type of the definition associated with the reference */ interface ReferenceNode : Node { /** * The reference element to associate with the definition. */ val reference: R } /** * @param context context where the [ResolvedReferenceProperty] is stored * @return the definition associated with [this] reference within the document handled by [context], * or `null` if the definition for [this] node is not registered or resolved */ fun ReferenceNode.getDefinition(context: Context): D? = context.attributes .of(this)[ResolvedReferenceProperty.Key()] ?.second /** * Registers the given [definition] as the definition associated with [this] reference within the document handled by [context]. * @param context context where the [ResolvedReferenceProperty] is stored * @param definition the definition to associate with [this] reference * @see com.quarkdown.core.context.hooks.reference.ReferenceDefinitionResolverHook for the assignment stage */ fun ReferenceNode.setDefinition( context: MutableContext, definition: D, ) { context.attributes.of(this) += ResolvedReferenceProperty(this.reference to definition) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/attributes/reference/ResolvedReferenceProperty.kt ================================================ package com.quarkdown.core.ast.attributes.reference import com.quarkdown.core.property.Property /** * A pair of a referenced linked to its resolved definition. */ typealias ResolvedReference = Pair /** * [Property] that can be assigned to each [ReferenceNode]. It contains the definition that the reference refers to. * @see ReferenceNode * @see com.quarkdown.core.context.hooks.reference.ReferenceDefinitionResolverHook for the assignment stage */ data class ResolvedReferenceProperty( override val value: ResolvedReference, ) : Property> { class Key : Property.Key> { override fun equals(other: Any?): Boolean = other is Key<*, *> override fun hashCode(): Int = Key::class.java.hashCode() } override val key = Key() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/LinkNode.kt ================================================ package com.quarkdown.core.ast.base import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.context.file.FileSystem /** * A general link node. * @see com.quarkdown.core.ast.base.inline.Link * @see com.quarkdown.core.ast.base.block.LinkDefinition */ interface LinkNode : Node { /** * Inline content of the displayed label. */ val label: InlineContent /** * URL this link points to. */ val url: String /** * Optional title. */ val title: String? /** * Optional file system where this link is defined, used for resolving relative paths. * @see com.quarkdown.core.context.hooks.LinkUrlResolverHook */ val fileSystem: FileSystem? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/TextNode.kt ================================================ package com.quarkdown.core.ast.base import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node /** * A node that may contain inline content as its children. */ interface TextNode : NestableNode { /** * The text of the node as processed inline content. */ val text: InlineContent override val children: List get() = text } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/BlankNode.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * Any unknown node type (should not happen). */ object BlankNode : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/BlockQuote.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A block quote. * @param type information type. If `null`, the quote does not have a particular type * @param attribution additional author or source of the quote * @param children content */ class BlockQuote( val type: Type? = null, val attribution: InlineContent? = null, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Type a [BlockQuote] might have. */ enum class Type : RenderRepresentable { TIP, NOTE, WARNING, IMPORTANT, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Code.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.localization.LocalizedKindKeys import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.quarkdown.CaptionableNode import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.visitor.node.NodeVisitor /** * A code block. * @param content code content * @param language optional syntax language * @param showLineNumbers whether to show line numbers * @param highlight whether to apply syntax highlighting * @param focusedLines range of lines to focus on. No lines are focused if `null` * @param caption optional caption * @param referenceId optional ID for cross-referencing via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] */ class Code( val content: String, val language: String?, val showLineNumbers: Boolean = true, val highlight: Boolean = true, val focusedLines: Range? = null, override val caption: String? = null, override val referenceId: String? = null, ) : LocationTrackableNode, CrossReferenceableNode, CaptionableNode, LocalizedKind { override val kindLocalizationKey: String get() = LocalizedKindKeys.CODE_BLOCK override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/FootnoteDefinition.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.attributes.id.Identifiable import com.quarkdown.core.ast.attributes.id.IdentifierProvider import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.numbering.DecimalNumberingSymbol import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.property.Property import com.quarkdown.core.visitor.node.NodeVisitor /** * Creation of a footnote definition, referenceable by a [com.quarkdown.core.ast.base.inline.ReferenceFootnote]. * @param label inline content of the referenceable label, which should match that of the [com.quarkdown.core.ast.base.inline.ReferenceFootnote]s * @param text inline content of the footnote * @param index index of the footnote in the document, in order of reference, or `null` if not linked to any reference */ class FootnoteDefinition( val label: String, override val text: InlineContent, ) : TextNode, Identifiable { override fun accept(visitor: NodeVisitor) = visitor.visit(this) override fun accept(visitor: IdentifierProvider) = visitor.visit(this) } /** * Property that stores the index of a [FootnoteDefinition] within the document. * The index associates the order of the footnote in the document according to the references to it. */ private data class FootnoteIndexProperty( override val value: Int, ) : Property { companion object : Property.Key override val key = FootnoteIndexProperty } /** * @param context context where footnote data is stored * @return the index of this footnote definition in the document, or `null` if it is not linked to any reference */ fun FootnoteDefinition.getIndex(context: Context): Int? = context.attributes.of(this)[FootnoteIndexProperty] /** * Registers the footnote index of this node within the document handled by [context], * according to the order of references to it. It is not updated if an index is already set. * @param context context where footnote data is stored * @param index index of the footnote definition in the document, in order of reference */ fun FootnoteDefinition.setIndex( context: MutableContext, index: Int, ) { if (getIndex(context) != null) return context.attributes.of(this) += FootnoteIndexProperty(index) } /** * Formats the index of this footnote definition according to the numbering format defined in the document, * or a default numbering format if none is defined. The default format is `1, 2, 3, ...` (decimal numbering). * @param context context where footnote data is stored * @return formatted index of the footnote definition, or `null` if it is not linked to any reference * @see getIndex */ fun FootnoteDefinition.getFormattedIndex(context: Context): String? { val index = getIndex(context) ?: return null val format = context.documentInfo.numberingOrDefault?.footnotes ?: NumberingFormat(DecimalNumberingSymbol) return format.format(index + 1) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Heading.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.attributes.id.Identifiable import com.quarkdown.core.ast.attributes.id.IdentifierProvider import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.localization.LocalizedKindKeys import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A heading defined via prefix symbols. * A heading is identifiable, as it can be looked up in the document and can be referenced. * It is also location trackable, meaning its position in the document hierarchy is determined, and possibly displayed. * @param depth importance (`depth=1` for H1, `depth=6` for H6) * @param customId optional custom ID. If `null`, the ID is automatically generated. If not `null`, the ID is used for cross-referencing. * @param canBreakPage whether this heading can trigger an automatic page break. * Decorative headings and auto-generated section headings typically disable this. * @param canTrackLocation whether this heading's position in the document hierarchy is tracked and displayed. * When `false`, the heading is not numbered. * @param excludeFromTableOfContents if `true`, this heading is never included in the table of contents, * even if its location is trackable. Useful for headings generated by functions * such as `.tableofcontents` and `.bibliography` to prevent self-referencing. */ class Heading( val depth: Int, override val text: InlineContent, val customId: String? = null, val canBreakPage: Boolean = true, override val canTrackLocation: Boolean = true, val excludeFromTableOfContents: Boolean = false, ) : TextNode, Identifiable, LocationTrackableNode, CrossReferenceableNode, LocalizedKind { override fun accept(visitor: NodeVisitor) = visitor.visit(this) override fun accept(visitor: IdentifierProvider) = visitor.visit(this) override val kindLocalizationKey: String get() = LocalizedKindKeys.HEADING /** * Whether this heading is decorative, i.e. it cannot trigger page breaks and its location is not tracked. */ val isDecorative: Boolean get() = !canBreakPage && !canTrackLocation && excludeFromTableOfContents /** * If the heading has a custom ID, it can be used for cross-referencing. */ override val referenceId: String? get() = this.customId companion object { /** * The minimum allowed heading depth (H1). * This does not take 0-depth headings into account. See [isMarker] for marker headings. */ const val MIN_DEPTH = 1 /** * The maximum allowed heading depth (H6). */ const val MAX_DEPTH = 6 } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/HeadingFactory.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.context.Context import com.quarkdown.core.context.localization.localizeOrNull /** * Creates an auto-generated [Heading] for a structural section of the document (e.g. table of contents, bibliography). * * The heading title is resolved from: * 1. A user-provided [title], if not `null` and not empty. * 2. A localized fallback from [localizationKey], if [title] is `null`. * 3. If neither resolves to text, the heading is still created with empty text when [customId] is set * (to serve as a referenceable anchor), or omitted (`null`) otherwise. * * An explicitly empty [title] means no heading should be displayed at all. * * The resulting heading is marked with [Heading.excludeFromTableOfContents] to prevent self-referencing * in the document's table of contents. * * @param title user-provided title content. * If `null`, the default localized title from [localizationKey] is used. * If empty, no heading is created. * @param localizationKey key to look up the default localized title if [title] is `null` * @param context context for localization * @param depth depth of the heading (1-6) * @param customId optional custom ID for cross-referencing. If set and no title is resolved, the heading * is still created with empty text to act as an anchor * @param canBreakPage whether the heading can trigger an automatic page break * @param canTrackLocation whether the heading's position should be tracked and numbered. * Implicitly enabled when [includeInTableOfContents] is `true`. * @param includeInTableOfContents whether this heading should be indexed in the document's table of contents. * Implicitly enables [canTrackLocation]. * @return a [Heading] node, or `null` if [title] is explicitly empty * or no title could be resolved and no [customId] is provided */ fun Heading.Companion.createSectionHeading( title: InlineContent?, localizationKey: String, context: Context, depth: Int = 1, customId: String? = null, canBreakPage: Boolean = true, canTrackLocation: Boolean = false, includeInTableOfContents: Boolean = false, ): Heading? { // An explicitly empty title means no heading should be shown. // null means "use default localized title", so null must not be treated as empty. if (title?.isEmpty() == true) { return null } val resolvedTitle = title ?: context.localizeOrNull(key = localizationKey)?.let { buildInline { text(it) } } ?: emptyList().takeIf { customId != null } ?: return null return Heading( depth = depth, text = resolvedTitle, customId = customId, canBreakPage = canBreakPage, canTrackLocation = canTrackLocation, excludeFromTableOfContents = !includeInTableOfContents, ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/HeadingMarker.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent /** * When a [Heading] has this depth value, it is considered an invisible referenceable mark. * Depth 0 cannot be achieved with plain Markdown, but it can be supplied by a Quarkdown function. */ private const val MARKER_HEADING_DEPTH = 0 /** * Whether this heading is a marker. * @see marker */ val Heading.isMarker: Boolean get() = depth == MARKER_HEADING_DEPTH /** * Creates an invisible [Heading] that acts as a marker that can be referenced by other elements in the document. * A useful use case would be, for example, in combination with a [com.quarkdown.core.context.toc.TableOfContents]. * Depth 0 cannot be achieved with plain Markdown, but it can be supplied by the Quarkdown function `.marker`. */ fun Heading.Companion.marker(name: InlineContent) = Heading(MARKER_HEADING_DEPTH, name, canBreakPage = false) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/HorizontalRule.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A horizontal line (thematic break). */ object HorizontalRule : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Html.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * An HTML block. * @param content raw HTML content */ class Html( val content: String, ) : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/LinkDefinition.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.visitor.node.NodeVisitor /** * Creation of a referenceable link definition. * @param label inline content of the displayed label * @param url URL this link points to * @param title optional title * @param fileSystem optional file system this link is relative to */ class LinkDefinition( override val label: InlineContent, override val url: String, override val title: String?, override val fileSystem: FileSystem? = null, ) : LinkNode, TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Alias for [label]. */ override val text: InlineContent get() = label } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Newline.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A blank line. */ object Newline : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Paragraph.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A general paragraph. * @param text text content */ class Paragraph( override val text: InlineContent, ) : TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/Table.kt ================================================ package com.quarkdown.core.ast.base.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.localization.LocalizedKindKeys import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.quarkdown.CaptionableNode import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A table, consisting of columns, each of which has a header and multiple cells. * A table is location-trackable since, if requested by the user, it may show a caption displaying its location-based label. * @param columns columns of the table. Each column has a header and multiple cells * @param caption optional caption of the table (Quarkdown extension) * @param referenceId optional ID of the table to cross-reference via [com.quarkdown.core.ast.quarkdown.reference.CrossReference] (Quarkdown extension) */ class Table( val columns: List, override val caption: String? = null, override val referenceId: String? = null, ) : NestableNode, LocationTrackableNode, CaptionableNode, CrossReferenceableNode, LocalizedKind { override val kindLocalizationKey: String get() = LocalizedKindKeys.TABLE // Exposing all the cell contents as this table's direct children // allows visiting them during a tree traversal. // If they were isolated, they would be unreachable. override val children: List get() = columns .asSequence() .flatMap { it.cells + it.header } .flatMap { it.text } .toList() /** * A column of a table. * @param alignment text alignment * @param header header cell * @param cells other cells */ data class Column( val alignment: Alignment, val header: Cell, val cells: List, ) /** * A mutable [Table.Column] which can be built incrementally. */ data class MutableColumn( var alignment: Alignment, val header: Cell, val cells: MutableList, ) { /** * @return an immutable [Table.Column] with the current state of this mutable column */ fun toColumn(): Column = Column(alignment, header, cells.toList()) } /** * A single cell of a table. * @param text content */ data class Cell( val text: InlineContent, ) /** * Text alignment of a [Column]. */ enum class Alignment : RenderRepresentable { LEFT, CENTER, RIGHT, NONE, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/list/List.kt ================================================ package com.quarkdown.core.ast.base.block.list import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A list, either ordered or unordered. */ interface ListBlock : NestableNode { /** * Whether the list is loose. */ val isLoose: Boolean /** * Items of the list. */ val items: List get() = children.filterIsInstance() } /** * An unordered list. * @param isLoose whether the list is loose * @param children items */ class UnorderedList( override val isLoose: Boolean, override val children: List, ) : ListBlock { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * An ordered list. * @param isLoose whether the list is loose * @param children items * @param startIndex index of the first item */ class OrderedList( val startIndex: Int, override val isLoose: Boolean, override val children: List, ) : ListBlock { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/list/ListItem.kt ================================================ package com.quarkdown.core.ast.base.block.list import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * An item of a [ListBlock]. A list item may be enhanced via [ListItemVariant]s. * @param variants additional functionalities and characteristics of this item. For example, this item may contain a checked/unchecked task. * @param children content */ class ListItem( val variants: List = emptyList(), override val children: List, ) : NestableNode { /** * The list that owns this item. * This property is set by the parser and should not be externally modified. */ var owner: ListBlock? = null override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/list/ListItemVariant.kt ================================================ package com.quarkdown.core.ast.base.block.list import com.quarkdown.core.ast.quarkdown.block.list.FocusListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.LocationTargetListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.TableOfContentsItemVariant /** * A variant of a [ListItem] that brings additional functionalities to it. */ interface ListItemVariant { /** * Accepts a [ListItemVariantVisitor]. * @param visitor visitor to accept * @return result of the visit operation */ fun accept(visitor: ListItemVariantVisitor): T } /** * Visitor of [ListItemVariant]s. * @param T return type of the visit operations */ interface ListItemVariantVisitor { fun visit(variant: TaskListItemVariant): T fun visit(variant: FocusListItemVariant): T fun visit(variant: LocationTargetListItemVariant): T fun visit(variant: TableOfContentsItemVariant): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/block/list/TaskListItemVariant.kt ================================================ package com.quarkdown.core.ast.base.block.list /** * A list item variant that adds a checkbox, which can be checked or unchecked, to a [ListItem]. * @param isChecked whether the item is checked */ data class TaskListItemVariant( val isChecked: Boolean, ) : ListItemVariant { override fun accept(visitor: ListItemVariantVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/CheckBox.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * An immutable checkbox that is either checked or unchecked. * @param isChecked whether the checkbox is checked * @see com.quarkdown.core.ast.base.block.TaskListItem */ class CheckBox( val isChecked: Boolean, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/CodeSpan.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.misc.color.Color import com.quarkdown.core.visitor.node.NodeVisitor /** * Inline code. * @param text text content * @param content additional content this code holds, if any */ class CodeSpan( override val text: String, val content: ContentInfo? = null, ) : PlainTextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Additional content a [CodeSpan] may hold. */ sealed interface ContentInfo /** * A color linked to a [CodeSpan]. * For instance, this content may be assigned to a [CodeSpan] if its text holds information about a color's hex. * @param color color data */ data class ColorContent( val color: Color, ) : ContentInfo } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/Comment.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A comment whose content is ignored. */ object Comment : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/Emphasis.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.visitor.node.NodeVisitor /** * Weakly emphasized content. * @param text content */ class Emphasis( override val text: InlineContent, ) : TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * Strongly emphasized content. * @param text content */ class Strong( override val text: InlineContent, ) : TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * Heavily emphasized content. * @param text content */ class StrongEmphasis( override val text: InlineContent, ) : TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * Strikethrough content. * @param text content */ class Strikethrough( override val text: InlineContent, ) : TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/Image.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.document.size.Size import com.quarkdown.core.visitor.node.NodeVisitor /** * An image. * @param link the link the image points to * @param width optional width constraint * @param height optional height constraint * @param referenceId optional ID that can be cross-referenced via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] */ class Image( val link: LinkNode, val width: Size?, val height: Size?, override val referenceId: String? = null, ) : CrossReferenceableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * An images that references a [LinkDefinition]. * @param link the link the image references * @param width optional width constraint * @param height optional height constraint * @param referenceId optional ID that can be cross-referenced via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] */ class ReferenceImage( val link: ReferenceLink, val width: Size?, val height: Size?, override val referenceId: String? = null, ) : CrossReferenceableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/LineBreak.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A hard line break. */ object LineBreak : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/Link.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.util.stripAnchor import com.quarkdown.core.visitor.node.NodeVisitor /** * A link. * @param label inline content of the displayed label * @param url URL this link points to * @param title optional title * @param fileSystem optional file system where this link is defined, used for resolving relative paths */ class Link( override val label: InlineContent, override val url: String, override val title: String?, override val fileSystem: FileSystem? = null, ) : LinkNode, TextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) override val text: InlineContent get() = label /** * Creates a copy of this link with the given [url]. */ fun copy(url: String) = Link( label = label, url = url, title = title, fileSystem = fileSystem, ) /** * Strips the anchor (fragment) from the URL. * @return a pair of the link with the anchor removed and the anchor itself, * or `null` if no anchor is present */ fun stripAnchor(): Pair? { val (url, anchor) = this.url.stripAnchor() ?: return null return Pair(copy(url = url), anchor) } } /** * A link that references a [LinkDefinition]. * @param label inline content of the displayed label * @param referenceLabel label of the [LinkDefinition] this link points to * @param fallback supplier of the node to show instead of [label] in case the reference is not resolved * @param onResolve actions to perform when the reference is resolved. * @see com.quarkdown.core.context.hooks.reference.LinkDefinitionResolverHook */ class ReferenceLink( val label: InlineContent, val referenceLabel: InlineContent, val fallback: () -> Node, val onResolve: MutableList<(resolved: LinkNode) -> Unit> = mutableListOf(), ) : ReferenceNode { override val reference: ReferenceLink = this override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/ReferenceFootnote.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.visitor.node.NodeVisitor /** * A reference to a [com.quarkdown.core.ast.base.block.FootnoteDefinition]. * @param label reference label that should match that of the footnote definition * @param fallback supplier of the node to show instead of [label] in case the reference is invalid */ class ReferenceFootnote( val label: String, val fallback: () -> Node, ) : ReferenceNode { override val reference: ReferenceFootnote = this override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * An all-in-one [ReferenceFootnote] that includes its [FootnoteDefinition]. * @param label the new label of the definition and reference * @param definition the content of the footnote definition */ class ReferenceDefinitionFootnote( val label: String, val definition: InlineContent, ) : NestableNode { override val children = listOf( ReferenceFootnote( label, fallback = { throw IllegalStateException("Reference + definition footnote should not need a fallback") }, ), FootnoteDefinition( label, definition, ), ) override fun accept(visitor: NodeVisitor): T = AstRoot(children).accept(visitor) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/SubdocumentLink.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.property.Property import com.quarkdown.core.visitor.node.NodeVisitor /** * A link to a Quarkdown subdocument. * @param link the link to the subdocument * @param anchor an optional anchor to a specific section within the subdocument */ class SubdocumentLink( val link: Link, val anchor: String? = null, ) : LinkNode by link, TextNode by link { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * A property that holds a reference to a [Subdocument] associated with a [SubdocumentLink] * during the tree traversal stage. * @see com.quarkdown.core.context.hooks.SubdocumentRegistrationHook for the registration stage */ data class SubdocumentProperty( override val value: Subdocument, ) : Property { companion object : Property.Key override val key: Property.Key = SubdocumentProperty } /** * @returns the [Subdocument] associated with this [SubdocumentLink] in the given [context], if any */ fun SubdocumentLink.getSubdocument(context: Context): Subdocument? = context.attributes.of(this)[SubdocumentProperty] /** * Associates a [Subdocument] with the [SubdocumentLink] in the given [context]. * @param context context where subdocument data is stored * @param subdocument the subdocument to set * @see com.quarkdown.core.context.hooks.SubdocumentRegistrationHook for the registration stage */ fun SubdocumentLink.setSubdocument( context: MutableContext, subdocument: Subdocument, ) { context.attributes.of(this) += SubdocumentProperty(subdocument) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/base/inline/Text.kt ================================================ package com.quarkdown.core.ast.base.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A [Node] that contains plain text. * @see com.quarkdown.core.util.node.toPlainText */ interface PlainTextNode : Node { val text: String } /** * Content (usually a single character) that requires special treatment during the rendering stage. * @param text wrapped text */ class CriticalContent( override val text: String, ) : PlainTextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } /** * Plain inline text. * @param text text content. */ class Text( override val text: String, ) : PlainTextNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/dsl/AstBuilder.kt ================================================ package com.quarkdown.core.ast.dsl import com.quarkdown.core.ast.Node /** * A builder of a [Node] tree. * @see BlockAstBuilder * @see InlineAstBuilder * @see ListAstBuilder */ open class AstBuilder { /** * The tree that is being built. */ protected val ast = mutableListOf() /** * Adds a node to the tree. * @param node node to add */ fun node(node: Node) { ast.add(node) } /** * Adds [this] node to the tree. Shorthand for [node] (DSL syntactic sugar). * Usage: `+node` */ operator fun Node.unaryPlus() = node(this) /** * Builds the tree. * @return the tree */ fun build() = ast.toList() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/dsl/BlockAstBuilder.kt ================================================ package com.quarkdown.core.ast.dsl import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.quarkdown.block.ImageFigure /** * A builder of block nodes. */ class BlockAstBuilder : AstBuilder() { /** * @see AstRoot */ fun root(block: BlockAstBuilder.() -> Unit) = +AstRoot(buildBlocks(block)) /** * @see Paragraph */ fun paragraph(block: InlineAstBuilder.() -> Unit) = +Paragraph(buildInline(block)) /** * @see Heading */ fun heading( level: Int, block: InlineAstBuilder.() -> Unit, ) = +Heading(level, buildInline(block)) /** * @see BlockQuote */ fun blockQuote( type: BlockQuote.Type? = null, attribution: (InlineAstBuilder.() -> Unit)? = null, block: BlockAstBuilder.() -> Unit, ) = +BlockQuote( type, attribution?.let(::buildInline), buildBlocks(block), ) /** * @see OrderedList * @see ListAstBuilder */ fun orderedList( startIndex: Int = 1, loose: Boolean, block: ListAstBuilder.() -> Unit, ) = +OrderedList(startIndex, loose, ListAstBuilder().apply(block).build()) /** * @see UnorderedList * @see ListAstBuilder */ fun unorderedList( loose: Boolean, block: ListAstBuilder.() -> Unit, ) = +UnorderedList(loose, ListAstBuilder().apply(block).build()) /** * @see Table * @see TableAstBuilder */ fun table( referenceId: String? = null, block: TableAstBuilder.() -> Unit, ) = +Table(TableAstBuilder().apply(block).columns, referenceId = referenceId) /** * @see ImageFigure */ fun figure(block: InlineAstBuilder.() -> Unit) = +ImageFigure(buildInline(block).single() as Image) } /** * Begins a DSL block for building block nodes. * @param block action to run with the block builder * @return the built nodes * @see BlockAstBuilder */ fun buildBlocks(block: BlockAstBuilder.() -> Unit): List = BlockAstBuilder().apply(block).build() /** * Begins a DSL block for building a single block node. * @param block action to run with the block builder * @return the first node that results from [buildBlocks] * @throws IllegalStateException if the result of [buildBlocks] is empty * @see BlockAstBuilder */ fun buildBlock(block: BlockAstBuilder.() -> Unit): Node = buildBlocks(block).firstOrNull() ?: throw IllegalStateException("buildBlock requires at least one node") ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/dsl/InlineAstBuilder.kt ================================================ package com.quarkdown.core.ast.dsl import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.context.file.SimpleFileSystem import com.quarkdown.core.document.size.Size /** * A builder of inline nodes. */ class InlineAstBuilder : AstBuilder() { /** * @see Strong */ fun strong(block: InlineAstBuilder.() -> Unit) = +Strong(buildInline(block)) /** * @see Emphasis */ fun emphasis(block: InlineAstBuilder.() -> Unit) = +Emphasis(buildInline(block)) /** * @see StrongEmphasis */ fun strongEmphasis(block: InlineAstBuilder.() -> Unit) = +StrongEmphasis(buildInline(block)) /** * @see Text */ fun text(text: String) = +Text(text) /** * @see TextTransform */ fun text( text: String, transform: TextTransformData, ) = +TextTransform(transform, children = buildInline { text(text) }) /** * @see Link */ fun link( url: String, title: String? = null, label: InlineAstBuilder.() -> Unit, ) = +Link(buildInline(label), url, title) /** * @see CodeSpan */ fun codeSpan(text: String) = +CodeSpan(text) /** * @see Image */ fun image( url: String, title: String? = null, width: Size? = null, height: Size? = null, referenceId: String? = null, label: InlineAstBuilder.() -> Unit = {}, ) = +Image( Link(buildInline(label), url, title, fileSystem = SimpleFileSystem()), width, height, referenceId, ) /** * @see InlineCollapse */ fun collapse( text: InlineAstBuilder.() -> Unit, placeholder: InlineAstBuilder.() -> Unit = { text(InlineCollapse.DEFAULT_PLACEHOLDER) }, isOpen: Boolean = false, ) = +InlineCollapse(buildInline(text), buildInline(placeholder), isOpen) /** * Automatically collapses a text if its length exceeds [maxLength]. * @see InlineCollapse */ fun autoCollapse( text: String, maxLength: Int, ) = collapse( text = { text(text) }, isOpen = text.length <= maxLength, ) /** * @see LineBreak */ fun lineBreak() = +LineBreak } /** * Begins a DSL block for building inline content. * @param block action to run with the inline builder * @return the built nodes * @see InlineAstBuilder */ fun buildInline(block: InlineAstBuilder.() -> Unit): InlineContent = InlineAstBuilder().apply(block).build() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/dsl/ListAstBuilder.kt ================================================ package com.quarkdown.core.ast.dsl import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariant /** * A builder of list items. * @see BlockAstBuilder.orderedList * @see BlockAstBuilder.unorderedList */ class ListAstBuilder : AstBuilder() { /** * @see ListItem */ fun listItem( vararg variants: ListItemVariant, block: BlockAstBuilder.() -> Unit, ) = node(ListItem(variants.toList(), buildBlocks(block))) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/dsl/TableAstBuilder.kt ================================================ package com.quarkdown.core.ast.dsl import com.quarkdown.core.ast.base.block.Table /** * A builder of table content. * @see BlockAstBuilder.table */ class TableAstBuilder : AstBuilder() { val columns = mutableListOf() /** * @see Table.Column */ fun column( header: InlineAstBuilder.() -> Unit, alignment: Table.Alignment = Table.Alignment.NONE, block: ColumnAstBuilder.() -> Unit, ) { val columnAstBuilder = ColumnAstBuilder().apply(block) columns += Table.Column(alignment, Table.Cell(buildInline(header)), columnAstBuilder.cells) } } /** * A builder of table columns. * @see TableAstBuilder.column */ class ColumnAstBuilder { val cells = mutableListOf() /** * @see Table.Cell */ fun cell(block: InlineAstBuilder.() -> Unit) { cells += Table.Cell(buildInline(block)) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/iterator/AstIterator.kt ================================================ package com.quarkdown.core.ast.iterator import com.quarkdown.core.ast.NestableNode /** * An iterator that runs through the nodes of an AST. */ interface AstIterator { /** * Runs the iterator from the given root node, * traversing the node tree and visiting each node. */ fun traverse(root: NestableNode) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/iterator/AstIteratorHook.kt ================================================ package com.quarkdown.core.ast.iterator /** * A hook that can be attached to an [ObservableAstIterator]. */ interface AstIteratorHook { /** * Attaches this hook to the given [iterator]. * @param iterator iterator to attach the hook to */ fun attach(iterator: ObservableAstIterator) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/iterator/ObservableAstIterator.kt ================================================ package com.quarkdown.core.ast.iterator import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.util.node.flattenedChildren /** * An iterator that performs a DFS traversal through the nodes of an AST, * allowing the registration of observers that will be notified when a node of a certain type is visited. */ class ObservableAstIterator : AstIterator { /** * Hooks that will be called when a node of a certain type is visited. */ val hooks: MutableList<(Node) -> Unit> = mutableListOf() /** * Hooks that will be called when the traversal finishes. */ private val onFinishedHooks: MutableList<() -> Unit> = mutableListOf() /** * Registers a hook that will be called when a node of type [T] is visited. * @param hook action to be called, with the visited node as parameter * @param T desired node type * @return this for concatenation */ inline fun on(noinline hook: (T) -> Unit): ObservableAstIterator = apply { hooks.add { if (it is T) hook(it) } } /** * Registers a hook that will be called when the tree traversal fully finishes. */ fun onFinished(hook: () -> Unit): ObservableAstIterator = apply { onFinishedHooks.add(hook) } /** * Collects the visited nodes of type [T] into a collection, as long as they satisfy a [condition]. * @param condition condition to be satisfied for the node to be collected * @param T node type * @return an ordered list (DFS order) containing all the visited nodes of type [T] in the tree */ inline fun collect(crossinline condition: (T) -> Boolean): List = mutableListOf().apply { on { if (condition(it)) add(it) } } /** * Collects all the visited nodes of type [T] into a collection. * @param T node type * @return an ordered list (DFS order) containing all the visited nodes of type [T] in the tree */ inline fun collectAll(): List = collect { true } /** * Attaches a hook to this iterator. * @param hook hook to attach * @return this for concatenation * @see on */ fun attach(hook: AstIteratorHook): ObservableAstIterator = apply { hook.attach(this) } override fun traverse(root: NestableNode) { root.flattenedChildren().forEach { node -> hooks.forEach { hook -> hook(node) } } onFinishedHooks.forEach { it() } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/media/StoredMediaProperty.kt ================================================ package com.quarkdown.core.ast.media import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.context.Context import com.quarkdown.core.media.storage.StoredMedia import com.quarkdown.core.property.Property /** * Property that can be attached to a [Node] in [AstAttributes.properties] * to signal that the node is bound to a [StoredMedia] resolved by a [com.quarkdown.core.media.storage.ReadOnlyMediaStorage]. * @param value the stored media * @see StoredMedia * @see com.quarkdown.core.media.storage.ReadOnlyMediaStorage * @see com.quarkdown.core.ast.attributes.AstAttributes.properties */ data class StoredMediaProperty( override val value: StoredMedia, ) : Property { companion object : Property.Key override val key: Property.Key = StoredMediaProperty } /** * Retrieves the stored media associated with [this] node, if any. * @param attributes the attributes to extract the properties from * @return the stored media associated with [this] node, if any */ internal fun Node.getStoredMedia(attributes: AstAttributes): StoredMedia? = attributes.of(this)[StoredMediaProperty] /** * Retrieves the stored media associated with [this] node, if any. * @param context the context to extract the properties from * @return the stored media associated with [this] node, if any */ fun Node.getStoredMedia(context: Context): StoredMedia? = getStoredMedia(context.attributes) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/CaptionableNode.kt ================================================ package com.quarkdown.core.ast.quarkdown import com.quarkdown.core.ast.Node /** * A node that may have a caption, such as a [com.quarkdown.core.ast.base.block.Table] or a [com.quarkdown.core.ast.quarkdown.block.ImageFigure]. * The caption is a plain text string, which does not accept further inline formatting. */ interface CaptionableNode : Node { /** * The optional caption. */ val caption: String? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/FunctionCallNode.kt ================================================ package com.quarkdown.core.ast.quarkdown import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.context.Context import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.visitor.node.NodeVisitor /** * A call to a function. * The call is executed after parsing, and its output is stored in its mutable [children]. * @param context context this node lies in, which is where symbols will be loaded from upon execution * @param name name of the function to call * @param arguments arguments to call the function with * @param isBlock whether this function call is an isolated block (opposite: inline) * @param sourceText if available, the source code of the whole function call * @param sourceRange if available, the range of the function call in the source code */ class FunctionCallNode( val context: Context, val name: String, val arguments: List, val isBlock: Boolean, val sourceText: CharSequence? = null, val sourceRange: IntRange? = null, ) : NestableNode { override val children: MutableList = mutableListOf() override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/bibliography/BibliographyCitation.kt ================================================ package com.quarkdown.core.ast.quarkdown.bibliography import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.visitor.node.NodeVisitor /** * Represents a citation to one or more bibliography entries. * Multiple keys produce a single combined label (e.g. `[1, 3]` or `(Einstein, 1905; Hawking, 1988)`), * according to the active citation style. * @param citationKeys the keys used to identify the bibliography entries */ class BibliographyCitation( val citationKeys: List, ) : ReferenceNode, BibliographyView>> { override val reference: BibliographyCitation = this override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/bibliography/BibliographyView.kt ================================================ package com.quarkdown.core.ast.quarkdown.bibliography import com.quarkdown.core.ast.Node import com.quarkdown.core.bibliography.Bibliography import com.quarkdown.core.bibliography.style.BibliographyStyle import com.quarkdown.core.visitor.node.NodeVisitor /** * Renderable container of a [bibliography]. * @param bibliography the bibliography to render * @param style the style to use for rendering the bibliography */ class BibliographyView( val bibliography: Bibliography, val style: BibliographyStyle, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Box.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.document.size.Size import com.quarkdown.core.misc.color.Color import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.util.takeLines import com.quarkdown.core.visitor.node.NodeVisitor /** * Maximum number of source text lines to show in an error box. */ private const val ERROR_MAX_SOURCE_TEXT_LINES = 10 /** * A generic box that contains content. * @param title box title. If `null`, the box is untitled * @param type type of the box * @param padding padding of the box. If `null`, the box uses the default value * @param backgroundColor background color of the box. If `null`, the box uses the default value * @param foregroundColor foreground color of the box. If `null`, the box uses the default value * @param children content of the box */ class Box( val title: InlineContent?, val type: Type, val padding: Size? = null, val backgroundColor: Color? = null, val foregroundColor: Color? = null, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Possible type of [Box], which determines its style. */ enum class Type : RenderRepresentable { /** * Content with higher importance. */ CALLOUT, /** * A tip. */ TIP, /** * A note. */ NOTE, /** * A warning. */ WARNING, /** * An error. */ ERROR, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } companion object { /** * A box that shows an error content with a monospaced text content. * @param content error message to display * @param title additional error title * @return a box containing the error message */ fun error( content: List, title: String? = null, ) = Box( title = buildInline { text("Error" + if (title != null) ": $title" else "") }, type = Type.ERROR, children = content, ) /** * A box that shows an error content with an optional source code snippet. * @param message error message to display * @param title additional error title * @param sourceText optional source code snippet to display * @return a box containing the error message */ fun error( message: InlineContent, title: String? = null, sourceText: CharSequence?, ): Box { val content = buildBlocks { +Paragraph(message) sourceText?.let { +Code( it.takeLines(ERROR_MAX_SOURCE_TEXT_LINES, addOmittedLinesSuffix = true), language = null, highlight = false, showLineNumbers = false, ) } } return error(content, title) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Clipped.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A block whose content is clipped in a path. * @param clip type of the clip path */ class Clipped( val clip: Clip, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Possible clip types of a [Clipped] block. */ enum class Clip : RenderRepresentable { CIRCLE, ; override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Collapse.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A collapsible block, whose content can be hidden or shown by interacting with it. * @param title title of the block * @param isOpen whether the block is open at the beginning */ class Collapse( val title: InlineContent, val isOpen: Boolean, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Container.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.misc.color.Color import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A general-purpose container that groups content. * @param width width of the container * @param height height of the container * @param fullWidth whether the container should take up the full width of the parent. Overridden by [width] * @param foregroundColor text color * @param backgroundColor background color * @param borderColor border color * @param borderWidth border width * @param borderStyle border style * @param margin whitespace outside the content * @param padding whitespace around the content * @param cornerRadius border radius of the container * @param alignment alignment of the content * @param textAlignment alignment of the text * @param textTransform transformation applied to the text content * @param float floating position of the container within the subsequent content * @param fullColumnSpan whether the container should span across all columns in a multi-column layout * @param className custom class name for the container, useful for applying custom styles, if supported by the renderer */ class Container( val width: Size? = null, val height: Size? = null, val fullWidth: Boolean = false, val foregroundColor: Color? = null, val backgroundColor: Color? = null, val borderColor: Color? = null, val borderWidth: Sizes? = null, val borderStyle: BorderStyle? = null, val margin: Sizes? = null, val padding: Sizes? = null, val cornerRadius: Sizes? = null, val alignment: Alignment? = null, val textAlignment: TextAlignment? = null, val textTransform: TextTransformData? = null, val float: FloatAlignment? = null, val fullColumnSpan: Boolean = false, val className: String? = null, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) /** * Possible alignment types of a [Container]. */ enum class Alignment : RenderRepresentable { START, CENTER, END, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Possible alignment types of a [Container]. * @param isLocal whether this alignment should be applied only to specific element types in the document, * rather than globally to the entire document */ enum class TextAlignment( val isLocal: Boolean = false, ) : RenderRepresentable { START, CENTER, END, JUSTIFY(isLocal = true), ; /** * Whether this alignment is applied globally to the document. This is complementary to [isLocal]. * If true, it will be applied to all elements in the document. * If false, it will only be applied to specific elements that support this alignment. */ val isGlobal: Boolean get() = !isLocal override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) companion object { /** * Converts an [Alignment] to a [TextAlignment], if applicable. */ fun fromAlignment(alignment: Alignment): TextAlignment? = when (alignment) { Alignment.START -> START Alignment.CENTER -> CENTER Alignment.END -> END } } } /** * Style of the border of a [Container]. */ enum class BorderStyle : RenderRepresentable { /** * Solid border. */ NORMAL, /** * Dashed border. */ DASHED, /** * Dotted border. */ DOTTED, /** * Double border. */ DOUBLE, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Floating position of a [Container]. */ enum class FloatAlignment : RenderRepresentable { START, END, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Figure.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.SingleChildNestableNode import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.localization.LocalizedKindKeys import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.quarkdown.CaptionableNode import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A block which displays a single child, with an optional caption. * If a [caption] is provided or [referenceId] is set, the block is numbered. * @param child wrapped child * @param caption optional caption of the figure block * @param referenceId optional ID that can be cross-referenced via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] * @param T type of the wrapped child node */ open class Figure( override val child: T, override val caption: String? = null, override val referenceId: String? = null, ) : SingleChildNestableNode, LocationTrackableNode, CrossReferenceableNode, CaptionableNode, LocalizedKind { override val kindLocalizationKey: String get() = LocalizedKindKeys.FIGURE /** * A figure is numbered if it has either a [caption] or a [referenceId]. */ override val canTrackLocation: Boolean get() = caption != null || referenceId != null override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } /** * An optionally-numbered block which displays a single image, with an optional caption. * The caption of the image matches the image title, if any. * @param child wrapped image * @see Image */ class ImageFigure( child: Image, ) : Figure( child, caption = child.link.title, referenceId = child.referenceId, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/FileTree.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A visual representation of a file system hierarchy, composed of [FileTreeEntry] elements. * @param entries top-level entries of the file tree * @see FileTreeEntry */ class FileTree( val entries: List, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } /** * A single entry in a [FileTree]. */ sealed interface FileTreeEntry { /** * Whether this entry is visually highlighted (e.g. rendered in bold). * An entry is highlighted when its text is wrapped in strong emphasis (`**name**`). */ val highlighted: Boolean /** * A leaf entry representing a file. * @param name file name, including extension * @param highlighted whether this file should be visually highlighted */ data class File( val name: String, override val highlighted: Boolean = false, ) : FileTreeEntry /** * A branch entry representing a directory, which contains nested [entries]. * @param name directory name * @param entries children of this directory (files and subdirectories) * @param highlighted whether this directory should be visually highlighted */ data class Directory( val name: String, val entries: List, override val highlighted: Boolean = false, ) : FileTreeEntry /** * A placeholder entry indicating omitted content, created by a `- ...` item. * @param highlighted whether this ellipsis should be visually highlighted */ data class Ellipsis( override val highlighted: Boolean = false, ) : FileTreeEntry } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Landscape.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * Transposes content to landscape orientation by rotating it 90 degrees counter-clockwise * with respect to the page size. */ class Landscape( override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Math.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A math (TeX) block. * * A math block can be cross-referenced and can be numbered, as long as it has a [referenceId]. * @param expression expression content * @param referenceId optional reference id for cross-referencing via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] */ class Math( val expression: String, override val referenceId: String? = null, ) : LocationTrackableNode, CrossReferenceableNode { /** * A math block is numbered if it has a [referenceId]. */ override val canTrackLocation: Boolean get() = referenceId != null override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/MermaidDiagram.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A Mermaid diagram. * @param code Mermaid code of the diagram */ class MermaidDiagram( val code: String, ) : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/NavigationContainer.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * Creates a navigation container, which marks its content as a navigable section. * * This doesn't affect the layout of the document by itself, but can be used by themes and renderers * to provide additional navigation features, styling, behaviors and accessibility. */ class NavigationContainer( val role: Role? = null, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) /** * Role of the [NavigationContainer], indicating its purpose in the document. * * Roles improve accessibility, and enable specific features in renderers and themes. */ enum class Role : RenderRepresentable { TABLE_OF_CONTENTS, PAGE_LIST, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Numbered.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.SectionLocation import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.visitor.node.NodeVisitor /** * Node that can be numbered depending on its location in the document * and the amount of occurrences according to its [key]. * * This node is peculiar, as it's the only node whose children are not evaluated directly during the function call expansion stage, * but rather during the AST traversal. * * This is because in order to evaluate the children, we need to know the location of the node in the document, * which is not known until the AST is fully traversed by [com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook]. * * After the traversal, the [com.quarkdown.core.context.hooks.location.NumberedEvaluatorHook] will evaluate and assign the [children] of this node, ready to be rendered. * * Since the evaluation does not happen within [com.quarkdown.core.function.call.FunctionCallNodeExpander], * errors thrown during the evaluation will have to be caught externally. This is handled by the hook itself, * which appends an error box (the same produced from the expander) to [children]. * From the user's perspective, this does not have any effect. * @param key name to group (and count) numbered nodes * @param referenceId optional ID for cross-referencing via a [com.quarkdown.core.ast.quarkdown.reference.CrossReference] * @param childrenSupplier supplier of the node content given the evaluated [SectionLocation], formatted according to the active [DocumentNumbering] * @see com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook for storing locations * @see com.quarkdown.core.context.hooks.location.NumberedEvaluatorHook for evaluating [location] and [childrenSupplier] * @see com.quarkdown.core.document.numbering.NumberingFormat */ class Numbered( val key: String, override val referenceId: String? = null, internal val childrenSupplier: (location: String) -> List, ) : NestableNode, LocationTrackableNode, CrossReferenceableNode { override var children: List = emptyList() override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/PageBreak.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A forced page break. */ class PageBreak : Node { override fun toString() = "PageBreak" override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/SlidesFragment.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A node that, when rendered in a `Slides` environment, * is displayed when the user attempts to go to the next slide. * Multiple fragments in the same slide are shown in order on distinct user interactions. * @param behavior visibility type of the fragment and how it reacts to user interactions */ class SlidesFragment( val behavior: Behavior, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) /** * Possible visibility types of a [SlidesFragment]. */ enum class Behavior : RenderRepresentable { /** * Starts invisible, fades in on interaction. */ SHOW, /** * Starts visible, fade out on interaction. */ HIDE, /** * Starts visible, fade out to 50% on interaction. */ SEMI_HIDE, /** * Starts invisible, fades in on interaction, then out on the next interaction. */ SHOW_HIDE, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/SlidesSpeakerNote.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A node that, when rendered in a `Slides` environment in speaker view, * contains speaker notes for the current slide. */ class SlidesSpeakerNote( override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/Stacked.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.document.size.Size import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * A block that contains nodes grouped together according to the given [layout]. * @param layout the way nodes are placed together * @param mainAxisAlignment content alignment along the main axis * @param crossAxisAlignment content alignment along the cross axis * @param rowGap vertical space between rows of nodes * @param columnGap horizontal space between columns of nodes */ class Stacked( val layout: Layout, val mainAxisAlignment: MainAxisAlignment, val crossAxisAlignment: CrossAxisAlignment, val rowGap: Size?, val columnGap: Size?, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) /** * Defines a way nodes of a [Stacked] block are placed together. * @see Column * @see Row * @see Grid */ sealed interface Layout : RenderRepresentable /** * A layout that stacks nodes vertically. */ data object Column : Layout { override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * A layout that stacks nodes horizontally. */ data object Row : Layout { override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * A layout that stacks nodes in a grid. * @param columnCount number of columns */ data class Grid( val columnCount: Int, ) : Layout { override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Possible alignment types along the main axis of a [Stacked] block. */ enum class MainAxisAlignment : RenderRepresentable { START, CENTER, END, SPACE_BETWEEN, SPACE_AROUND, SPACE_EVENLY, ; override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } /** * Possible alignment types along the cross axis of a [Stacked] block. */ enum class CrossAxisAlignment : RenderRepresentable { START, CENTER, END, STRETCH, ; override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/SubdocumentGraph.kt ================================================ package com.quarkdown.core.ast.quarkdown.block import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A graph representing the relationships between [com.quarkdown.core.document.sub.Subdocument]s * within the document, stored in [com.quarkdown.core.context.Context.sharedSubdocumentsData]. */ class SubdocumentGraph : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/list/FocusListItemVariant.kt ================================================ package com.quarkdown.core.ast.quarkdown.block.list import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariant import com.quarkdown.core.ast.base.block.list.ListItemVariantVisitor /** * A list item variant that adds focus to a [ListItem]. * When at least one item in a list is focused, the other items are rendered so that the focused ones are more visible. * This property has an effect only when using a Quarkdown renderer. * @param isFocused whether the item is focused. */ data class FocusListItemVariant( val isFocused: Boolean, ) : ListItemVariant { override fun accept(visitor: ListItemVariantVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/list/LocationTargetListItemVariant.kt ================================================ package com.quarkdown.core.ast.quarkdown.block.list import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariant import com.quarkdown.core.ast.base.block.list.ListItemVariantVisitor import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat /** * Variant of a [ListItem] that displays the location of a target node, * usually (rendering-dependent) by replacing the item marker with [target]'s position, * formatted according to the global numbering format. * This is used, for example, in table of contents. * @param target node to display the location of * @param format kind of numbering format to use to format the location */ data class LocationTargetListItemVariant( val target: LocationTrackableNode, val format: (DocumentNumbering) -> NumberingFormat?, ) : ListItemVariant { override fun accept(visitor: ListItemVariantVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/list/TableOfContentsItemVariant.kt ================================================ package com.quarkdown.core.ast.quarkdown.block.list import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariant import com.quarkdown.core.ast.base.block.list.ListItemVariantVisitor import com.quarkdown.core.context.toc.TableOfContents /** * A list item variant that associates a [ListItem] to an item of a [TableOfContents], * such as a [com.quarkdown.core.ast.base.block.Heading]. * @param item the ToC item associated with the list item */ data class TableOfContentsItemVariant( val item: TableOfContents.Item, ) : ListItemVariant { override fun accept(visitor: ListItemVariantVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/toc/TableOfContentsUtils.kt ================================================ package com.quarkdown.core.ast.quarkdown.block.toc import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.quarkdown.block.list.FocusListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.LocationTargetListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.TableOfContentsItemVariant import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.util.node.stripRichContent import com.quarkdown.core.visitor.node.NodeVisitor /** * Filters [TableOfContents.Item]s based on the given [TableOfContentsView]'s configuration: * items that exceed the maximum depth are filtered out. * @returns a sequence of filtered [TableOfContents.Item]s. */ private fun filterTableOfContentsItems( view: TableOfContentsView, items: List, ): Sequence = items .asSequence() .filter { it.depth <= view.maxDepth } /** * Converts a table of contents to a renderable [OrderedList]. * @param renderer renderer to use to render items * @param items ToC items [view] contains, prior to filtering * @param loose whether the list should be rendered in loose mode * @param wrapLinksInParagraphs whether to wrap the links in paragraphs * @param linkUrlMapper function that obtains the URL to send to when a ToC item is interacted with */ fun convertTableOfContentsToListNode( view: TableOfContentsView, renderer: NodeVisitor, items: List, loose: Boolean = true, wrapLinksInParagraphs: Boolean = false, linkUrlMapper: (TableOfContents.Item) -> String, ): OrderedList { // Gets the content of an inner (nested, level 2+ headings) ToC item. fun getNestedItemContent(item: TableOfContents.Item) = listOfNotNull( Link( // Rich content of the heading is ignored in the ToC entry. item.text.stripRichContent(renderer), url = linkUrlMapper(item), title = null, ).let { if (wrapLinksInParagraphs) Paragraph(listOf(it)) else it }, // Recursively include sub-items. filterTableOfContentsItems(view, item.subItems) .takeIf { it.any() } ?.let { convertTableOfContentsToListNode(view, renderer, it.toList(), loose, wrapLinksInParagraphs, linkUrlMapper) }, ) // Level 1 headings. return OrderedList( startIndex = 1, isLoose = loose, children = filterTableOfContentsItems(view, items) .map { ListItem( children = getNestedItemContent(it), variants = buildList { this += TableOfContentsItemVariant(it) // When at least one item is focused, the other items are less visible. this += FocusListItemVariant(isFocused = view.hasFocus(it)) // If the target node's location can be tracked, // the list item displays its location. // Since the targets are usually headings, thus location trackable, this is applied. // Only add location variant for numbered headings. if (it.target is LocationTrackableNode && it.target.canTrackLocation) { this += LocationTargetListItemVariant(it.target, DocumentNumbering::headings) } }, ) }.toList(), ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/block/toc/TableOfContentsView.kt ================================================ package com.quarkdown.core.ast.quarkdown.block.toc import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.util.node.toPlainText import com.quarkdown.core.visitor.node.NodeVisitor /** * When this node is rendered, the current table of contents, * retrieved from the auto-generated [AstAttributes.tableOfContents], is displayed. * @param maxDepth maximum depth the table of contents to display. * For instance, if `maxDepth` is 2, only headings of level 1 and 2 will be displayed * @param focusedItem if not `null`, adds focus to the item of the table of contents with the same text content as this value */ class TableOfContentsView( val maxDepth: Int, private val focusedItem: InlineContent? = null, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) /** * @param item table of contents item to compare * @return whether the given item of a table of contents should be focused, according to the [focusedItem] property. * Their pure text content (ignoring formatting) is compared. */ fun hasFocus(item: TableOfContents.Item) = focusedItem != null && item.text.toPlainText() == focusedItem.toPlainText() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/IconImage.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * An icon node, which shows a pixel-perfect image from the icon library by its name. * * Note: icon libraries and names are dependent on the renderer. * No validation is performed by the compiler, and missing icons may not be rendered or rendered incorrectly. * * @param name the name of the icon */ class IconImage( val name: String, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/InlineCollapse.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A collapsible block, whose content can be hidden or shown by interacting with it. * @param text expanded content * @param placeholder content to show when the node is collapsed * @param isOpen whether the node is expanded at the beginning */ class InlineCollapse( override val text: InlineContent, val placeholder: InlineContent, val isOpen: Boolean, ) : TextNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) companion object { /** * A default placeholder for the collapsed state of a [InlineCollapse]. */ const val DEFAULT_PLACEHOLDER = "(...)" } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/LastHeading.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * Node that displays the last heading encountered, of the given [depth], before the current position. * @param depth the depth of the last [com.quarkdown.core.ast.base.block.Heading] to match */ class LastHeading( val depth: Int, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/MathSpan.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A math (TeX) inline. * @param expression expression content */ class MathSpan( val expression: String, ) : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/PageCounter.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * A counter for the current or total page number. * In case the current document type does not support page counting (e.g. plain document), * a placeholder is used at rendering time. * @param target whether the counter should display the current or total page number */ class PageCounter( val target: Target, ) : Node { enum class Target { /** * The current page number. */ CURRENT, /** * The total amount of pages. */ TOTAL, } override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/TextSymbol.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.base.inline.PlainTextNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A text-based symbol, such as `©`, `…`, `≥`. * This is usually the result of a combination of multiple characters (e.g. `(C)` -> `©`). * @param symbol processed symbol (e.g. `©`) * @see com.quarkdown.core.lexer.patterns.TextSymbolReplacement */ class TextSymbol( private val symbol: Char, ) : PlainTextNode { /** * @return [symbol] as a string */ override val text: String get() = symbol.toString() override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/TextTransform.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.misc.color.Color import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.visitor.node.NodeVisitor /** * Text transformation a portion of text can undergo. * If a property is set to `null` it is not specified, hence ignored * and the default value is used. * @param size font size * @param weight font weight * @param style font style * @param decoration text decoration * @param case text case * @param variant font variant * @param script vertical script position (subscript or superscript) * @param color text (foreground) color */ class TextTransformData( val size: Size? = null, val weight: Weight? = null, val style: Style? = null, val decoration: Decoration? = null, val case: Case? = null, val variant: Variant? = null, val script: Script? = null, val color: Color? = null, ) { /** * Font size, relative to the default font size. */ enum class Size : RenderRepresentable { /** * Tiny font size (50%). */ TINY, /** * Small font size (75%). */ SMALL, /** * Normal font size (100%). */ NORMAL, /** * Medium font size (125%). */ MEDIUM, /** * Large font size (150%). */ LARGE, /** * Larger font size (200%). */ LARGER, /** * Huge font size (300%). */ HUGE, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Font weight. */ enum class Weight : RenderRepresentable { /** * Normal font weight. */ NORMAL, /** * Bold font weight. */ BOLD, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Font style. */ enum class Style : RenderRepresentable { /** * Normal font style. */ NORMAL, /** * Italic font style. */ ITALIC, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Text decoration. */ enum class Decoration : RenderRepresentable { /** * No text decoration. */ NONE, /** * Line under the text. */ UNDERLINE, /** * Line over the text. */ OVERLINE, /** * Lines under and over the text. */ UNDEROVERLINE, /** * Line through the text. */ STRIKETHROUGH, /** * Lines under, over and through the text. */ ALL, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Text case transformation. */ enum class Case : RenderRepresentable { /** * No text case transformation. */ NONE, /** * Uppercase text. */ UPPERCASE, /** * Lowercase text. */ LOWERCASE, /** * Capitalize text (first letter of each word is uppercase). */ CAPITALIZE, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Font variant. */ enum class Variant : RenderRepresentable { /** * No font variant. */ NORMAL, /** * Small-caps font variant. */ SMALL_CAPS, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } /** * Vertical script position. */ enum class Script : RenderRepresentable { /** * Subscript text. */ SUB, /** * Superscript text. */ SUP, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } } /** * A portion of text with a specific visual transformation. * @param data transformation the text undergoes * @param className custom class name for the element, useful for applying custom styles, if supported by the renderer */ class TextTransform( val data: TextTransformData, val className: String? = null, override val children: List, ) : NestableNode { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/inline/Whitespace.kt ================================================ package com.quarkdown.core.ast.quarkdown.inline import com.quarkdown.core.ast.Node import com.quarkdown.core.document.size.Size import com.quarkdown.core.visitor.node.NodeVisitor /** * An empty square that adds whitespace to the layout. * If both width and height are `null`, the whitespace consists of a blank space. * @param width width of the whitespace * @param height height of the whitespace */ class Whitespace( val width: Size?, val height: Size?, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/invisible/PageMarginContentInitializer.kt ================================================ package com.quarkdown.core.ast.quarkdown.invisible import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.document.layout.page.PageMarginPosition import com.quarkdown.core.visitor.node.NodeVisitor /** * A non-visible node that triggers a property in paged documents that allows displaying content on each page. * @param children content to be displayed on each page * @param position position of the content within the page */ class PageMarginContentInitializer( override val children: List, val position: PageMarginPosition, ) : NestableNode { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/invisible/PageNumberFormatter.kt ================================================ package com.quarkdown.core.ast.quarkdown.invisible import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * Marker node used to specify the format of page numbers during rendering. * @param format the format string for page numbers, e.g. "1", "i", "A". */ class PageNumberFormatter( val format: String, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/invisible/PageNumberReset.kt ================================================ package com.quarkdown.core.ast.quarkdown.invisible import com.quarkdown.core.ast.Node import com.quarkdown.core.visitor.node.NodeVisitor /** * Marker node used to reset the logical page number during rendering. * @param startFrom the page number to start from after the reset */ class PageNumberReset( val startFrom: Int, ) : Node { override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/invisible/SlidesConfigurationInitializer.kt ================================================ package com.quarkdown.core.ast.quarkdown.invisible import com.quarkdown.core.ast.Node import com.quarkdown.core.document.slides.Transition import com.quarkdown.core.visitor.node.NodeVisitor /** * A non-visible node that injects properties that affect the global configuration for slides documents. * If not specified, the default values of the underlying renderer are used. * @param centerVertically whether slides should be centered vertically * @param showControls whether navigation controls should be shown * @param showNotes whether speaker notes should be shown when not in speaker view * @param transition global transition between slides */ class SlidesConfigurationInitializer( val centerVertically: Boolean?, val showControls: Boolean?, val showNotes: Boolean?, val transition: Transition?, ) : Node { override fun accept(visitor: NodeVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/reference/CrossReference.kt ================================================ package com.quarkdown.core.ast.quarkdown.reference import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.visitor.node.NodeVisitor /** * A cross-reference to a [CrossReferenceableNode] within the same document. * The link with the target is made by matching [referenceId]s. * @param referenceId the reference ID of the target node being referenced */ class CrossReference( val referenceId: String, ) : ReferenceNode { override val reference: CrossReference = this override fun accept(visitor: NodeVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/ast/quarkdown/reference/CrossReferenceableNode.kt ================================================ package com.quarkdown.core.ast.quarkdown.reference import com.quarkdown.core.ast.Node /** * A node that can be referenced by a [CrossReference], by means of matching [referenceId]s. */ interface CrossReferenceableNode : Node { /** * The ID used to reference this node. * If `null`, this node cannot be referenced. * In order to be referenced by a [CrossReference], this ID must match the ID of the [CrossReference]. */ val referenceId: String? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/Bibliography.kt ================================================ package com.quarkdown.core.bibliography /** * A document's bibliography. * @param entries the bibliography entries associated with their citation keys. */ class Bibliography( val entries: Map, ) { /** * @return the index of the given [entry] in the bibliography */ fun indexOf(entry: BibliographyEntry): Int = entries.values.indexOf(entry) } /** * A single bibliography entry, identified by a unique [citationKey]. * Formatting and content are handled externally by the [com.quarkdown.core.bibliography.style.BibliographyStyle]. */ class BibliographyEntry( /** * The unique identifier for the bibliography entry, used as a citation key. */ val citationKey: String, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/BibliographyEntryLabelProviderStrategy.kt ================================================ package com.quarkdown.core.bibliography.style import com.quarkdown.core.bibliography.BibliographyEntry /** * Supplier of citation labels for bibliography entries. * Labels serve two purposes: * - **Citation labels** appear inline in the document text (e.g. `[1]` or `(Einstein, 1905)`). * - **List labels** appear next to each entry in the bibliography list (e.g. `[1]` or empty for APA). */ interface BibliographyEntryLabelProviderStrategy { /** * Returns the combined label for an in-text citation of one or more bibliography entries * (e.g. `[1]`, `[1, 2]`, or `(Einstein, 1905; Hawking, 1988)`). * @param entries the bibliography entries being cited */ fun getCitationLabel(entries: List): String /** * Returns the label for a bibliography list entry (e.g. `[1]` or empty). * @param entry the bibliography entry in the list * @param index the index of the entry in the bibliography list, starting from 0 */ fun getListLabel( entry: BibliographyEntry, index: Int, ): String } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/BibliographyStyle.kt ================================================ package com.quarkdown.core.bibliography.style import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.bibliography.BibliographyEntry /** * Defines how bibliography entries and citations are formatted. * Implementations provide both a [labelProvider] for citation labels and list labels, * and a [contentProvider] for the formatted entry content. * * The primary implementation is [com.quarkdown.core.bibliography.style.csl.CslBibliographyStyle], * which supports any [CSL](https://citationstyles.org) style definition. */ interface BibliographyStyle { /** * Strategy to retrieve citation labels for bibliography entries. */ val labelProvider: BibliographyEntryLabelProviderStrategy /** * Provides the formatted inline content for a bibliography entry. * @param entry the bibliography entry to format * @return the formatted inline content */ fun contentOf(entry: BibliographyEntry): InlineContent /** * The name of this bibliography style. */ val name: String } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/csl/CslBibliographyStyle.kt ================================================ package com.quarkdown.core.bibliography.style.csl import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.bibliography.Bibliography import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.bibliography.style.BibliographyEntryLabelProviderStrategy import com.quarkdown.core.bibliography.style.BibliographyStyle import com.quarkdown.core.localization.Locale import com.quarkdown.core.util.node.toPlainText import de.undercouch.citeproc.BibliographyFileReader import de.undercouch.citeproc.CSL import de.undercouch.citeproc.ItemDataProvider import java.io.IOException import java.io.InputStream /** * A [BibliographyStyle] backed by a [CSL](https://citationstyles.org) style definition, * powered by [citeproc-java](https://github.com/michel-kraemer/citeproc-java). * * This enables support for a curated selection of citation styles from the * [CSL Style Repository](https://github.com/citation-style-language/styles), * including BibTeX, CSL JSON, YAML, EndNote, and RIS bibliography sources. * * Citation label and entry content formatting are delegated to citeproc-java, * which processes the CSL XML style definition and produces structured output * converted to Quarkdown AST nodes via [QuarkdownCslFormat] and [CslTokenConverter]. * * @param cslStyleName the CSL style identifier (e.g. `"apa"`, `"ieee"`, `"chicago-author-date"`) * @param provider the item data provider supplying bibliography data to citeproc-java * @param locale optional [RFC 4646](https://www.rfc-editor.org/rfc/rfc4646) locale tag * (e.g. `"en-US"`, `"de-DE"`). Controls localized terms such as "and"/"und", * month names, and ordinal suffixes. When `null`, the style's default locale is used, * falling back to `"en-US"`. * @see QuarkdownCslFormat * @see CslTokenConverter */ class CslBibliographyStyle( private val cslStyleName: String, private val provider: ItemDataProvider, locale: String? = null, ) : BibliographyStyle { private val format = QuarkdownCslFormat() private val csl = CSL(provider, cslStyleName, locale).apply { setOutputFormat(format) registerCitationItems(provider.ids) } /** * The [Bibliography] derived from the provider's entry IDs. */ val bibliography: Bibliography by lazy { Bibliography( provider.ids.associateWith(::BibliographyEntry), ) } /** * Lazily formatted bibliography entries, mapping each citation key * to its [FormattedBibliographyEntry] (label + content). * * Triggering this lazy value calls [CSL.makeBibliography], which invokes * [QuarkdownCslFormat.doFormatBibliographyEntry] for each entry sequentially. * The accumulated results are then matched to provider IDs by position. */ private val formattedEntries: Map by lazy { format.bibliographyEntries.clear() csl.makeBibliography() provider.ids.zip(format.bibliographyEntries).toMap() } override val name: String get() = cslStyleName override val labelProvider = object : BibliographyEntryLabelProviderStrategy { override fun getCitationLabel(entries: List): String { csl.makeCitation(*entries.map { it.citationKey }.toTypedArray()) return format.lastCitationResult.toPlainText().ifBlank { "[?]" } } override fun getListLabel( entry: BibliographyEntry, index: Int, ): String = formattedEntries[entry.citationKey]?.label ?: "" } override fun contentOf(entry: BibliographyEntry): InlineContent = formattedEntries[entry.citationKey]?.content ?: emptyList() companion object { /** * Reads a bibliography file and creates a [CslBibliographyStyle]. * Supports BibTeX (`.bib`), CSL JSON, YAML, EndNote, and RIS formats. * @param cslStyleName the CSL style identifier * @param input the input stream for the bibliography source * @param filename the filename hint for format detection * @param locale optional [Locale] for localized terms (e.g. "and"/"und", month names). * When `null`, the style's default locale is used * @return a new [CslBibliographyStyle] */ fun from( cslStyleName: String, input: InputStream, filename: String, locale: Locale? = null, ): CslBibliographyStyle { val provider = BibliographyFileReader().readBibliographyFile(input, filename) return try { CslBibliographyStyle(cslStyleName, provider, locale?.tag) } catch (e: IOException) { throw IllegalArgumentException( "Bibliography style '$cslStyleName' does not exist or failed to load. " + "See https://quarkdown.com/wiki/bibliography for a list of available styles.", e, ) } } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/csl/CslTokenConverter.kt ================================================ package com.quarkdown.core.bibliography.style.csl import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import de.undercouch.citeproc.csl.internal.TokenBuffer import de.undercouch.citeproc.csl.internal.behavior.FormattingAttributes import de.undercouch.citeproc.csl.internal.token.DisplayGroupToken import de.undercouch.citeproc.csl.internal.token.TextToken /** * Converts citeproc-java [TokenBuffer] tokens into Quarkdown [InlineContent] AST nodes. * * This is the bridge between citeproc-java's internal token representation and Quarkdown's AST, * mapping formatting attributes (italic, bold, small caps) and token types (URL, DOI) to * their corresponding Quarkdown node types. * * @param urlFormatter resolves raw URL/DOI text into display URLs * (e.g. prepending `https://doi.org/` to DOIs) */ internal class CslTokenConverter( private val urlFormatter: UrlFormatter, ) { /** * Resolves raw URL/DOI text into display URLs. */ fun interface UrlFormatter { /** * @param text the raw URL or DOI text * @param type the token type (URL or DOI) * @return the formatted URL string */ fun format( text: String, type: TextToken.Type, ): String } /** * Converts all tokens in a [TokenBuffer] into Quarkdown [InlineContent] AST nodes. * * [TextToken]s are converted based on their type and formatting attributes. * [DisplayGroupToken]s are skipped, as Quarkdown handles layout via CSS. */ fun convert(buffer: TokenBuffer): InlineContent = buildList { for (token in buffer.tokens) { when (token) { is TextToken -> if (token.text.isNotEmpty()) add(convertTextToken(token)) is DisplayGroupToken -> continue } } } /** * Extracts plain text from a [TokenBuffer], discarding formatting. */ fun extractPlainText(buffer: TokenBuffer): String = buffer.tokens .filterIsInstance() .joinToString("") { it.text } .trim() /** * Converts a single [TextToken] into a Quarkdown AST node. * * URL/DOI tokens produce [Link] nodes. Other tokens produce [Text] nodes, * optionally wrapped in [Emphasis], [Strong], or [TextTransform] * based on their [FormattingAttributes]. */ private fun convertTextToken(token: TextToken): Node { if (token.type == TextToken.Type.URL || token.type == TextToken.Type.DOI) { return convertLinkToken(token.type, token.text) } return applyFormatting(Text(token.text), token.formattingAttributes) } /** * Converts a URL or DOI token into a [Link] node. */ private fun convertLinkToken( type: TextToken.Type, text: String, ): Link { val url = urlFormatter.format(text, type) return Link( label = listOf(Text(url)), url = url, title = null, ) } /** * Wraps a [node] in formatting nodes based on the given [FormattingAttributes] bitmask. * Attributes are applied innermost to outermost: italic, then bold, then small caps. */ private fun applyFormatting( node: Node, attrs: Int, ): Node { var result = node if (FormattingAttributes.getFontStyle(attrs) == FormattingAttributes.FS_ITALIC) { result = Emphasis(text = listOf(result)) } if (FormattingAttributes.getFontWeight(attrs) == FormattingAttributes.FW_BOLD) { result = Strong(text = listOf(result)) } if (FormattingAttributes.getFontVariant(attrs) == FormattingAttributes.FV_SMALLCAPS) { result = TextTransform( data = TextTransformData(variant = TextTransformData.Variant.SMALL_CAPS), children = listOf(result), ) } return result } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/csl/FormattedBibliographyEntry.kt ================================================ package com.quarkdown.core.bibliography.style.csl import com.quarkdown.core.ast.InlineContent /** * A fully formatted bibliography entry produced by the CSL processor. * @param label the entry label (e.g. `[1]` for numbered styles, empty for author-year styles) * @param content the formatted entry content as Quarkdown AST nodes */ internal data class FormattedBibliographyEntry( val label: String, val content: InlineContent, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/bibliography/style/csl/QuarkdownCslFormat.kt ================================================ package com.quarkdown.core.bibliography.style.csl import com.quarkdown.core.ast.InlineContent import de.undercouch.citeproc.csl.internal.RenderContext import de.undercouch.citeproc.csl.internal.SBibliography import de.undercouch.citeproc.csl.internal.TokenBuffer import de.undercouch.citeproc.csl.internal.format.BaseFormat import de.undercouch.citeproc.csl.internal.token.TextToken import de.undercouch.citeproc.output.Bibliography import de.undercouch.citeproc.output.SecondFieldAlign /** * A custom `citeproc-java` [BaseFormat] that produces Quarkdown AST nodes * instead of formatted strings. * * Since citeproc-java's API is string-based, the `doFormat*` callbacks return empty strings * while storing structured AST results in side-channel fields: * - [bibliographyEntries]: accumulated during [doFormatBibliographyEntry] calls. * - [lastCitationResult]: set by [doFormatCitation], consumed immediately after. * * Token-to-AST conversion is delegated to [CslTokenConverter]. */ internal class QuarkdownCslFormat : BaseFormat() { private val tokenConverter = CslTokenConverter { text, type -> when (type) { TextToken.Type.DOI -> formatDOI(text) else -> formatURL(text) } } /** * Accumulated formatted bibliography entries, populated sequentially * during [de.undercouch.citeproc.CSL.makeBibliography] calls. */ val bibliographyEntries: MutableList = mutableListOf() /** * The most recent citation result, set by [doFormatCitation] * and consumed by the caller immediately after. */ var lastCitationResult: InlineContent = emptyList() private set override fun getName(): String = "quarkdown" override fun doFormatCitation( buffer: TokenBuffer, renderContext: RenderContext, ): String { lastCitationResult = tokenConverter.convert(buffer) return "" } override fun doFormatBibliographyEntry( buffer: TokenBuffer, renderContext: RenderContext, index: Int, ): String { bibliographyEntries += formatEntry(buffer, renderContext) return "" } override fun doFormatLink( text: String, uri: String, ): String = text // Links are handled directly in CslTokenConverter. override fun makeBibliography( entries: Array, bibliography: SBibliography, ): Bibliography = Bibliography(*entries) /** * Formats a single bibliography entry, splitting it into a label and content * when the CSL style uses * [second-field-align](https://docs.citationstyles.org/en/stable/specification.html#bibliography-specific-options). * * Styles with `second-field-align` (e.g. IEEE) split the token buffer into: * - **First-field tokens**: the entry label (e.g. `[1]`), extracted as plain text * - **Remaining tokens**: the entry content, converted to Quarkdown AST nodes * * Styles without it (e.g. APA) treat the entire buffer as content. */ private fun formatEntry( buffer: TokenBuffer, renderContext: RenderContext, ): FormattedBibliographyEntry { val secondFieldAlign = renderContext.style.bibliography?.secondFieldAlign if (secondFieldAlign == null || secondFieldAlign == SecondFieldAlign.FALSE) { return FormattedBibliographyEntry(label = "", content = tokenConverter.convert(buffer)) } val tokens = buffer.tokens val contentStart = tokens.indexOfFirst { !it.isFirstField } if (contentStart <= 0) { return FormattedBibliographyEntry(label = "", content = tokenConverter.convert(buffer)) } val label = tokenConverter.extractPlainText(buffer.copy(0, contentStart)) val content = tokenConverter.convert(buffer.copy(contentStart, tokens.size)) return FormattedBibliographyEntry(label, content) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/BaseContext.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.context.file.SimpleFileSystem import com.quarkdown.core.context.subdocument.SubdocumentsData import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.function.Function import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.UncheckedFunctionCall import com.quarkdown.core.function.library.Library import com.quarkdown.core.graph.DirectedGraph import com.quarkdown.core.graph.VisitableOnceGraph import com.quarkdown.core.graph.visitableOnce import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocalizationKeyNotFoundException import com.quarkdown.core.localization.LocalizationLocaleNotFoundException import com.quarkdown.core.localization.LocalizationTable import com.quarkdown.core.localization.LocalizationTableNotFoundException import com.quarkdown.core.media.storage.MutableMediaStorage import com.quarkdown.core.media.storage.ReadOnlyMediaStorage import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.Pipelines /** * An immutable [Context] implementation. * This might be used in tests as a toy context, but in a concrete execution, its mutable subclass [MutableContext] is used. * @param attributes attributes of the node tree, produced by the parsing stage * @param flavor Markdown flavor used for this pipeline. It specifies how to produce the needed components * @param libraries loaded libraries to look up functions from * @param subdocument the subdocument this context is processing */ open class BaseContext( override val attributes: AstAttributes, override val flavor: MarkdownFlavor, override val libraries: Set = emptySet(), override val subdocument: Subdocument = Subdocument.Root, ) : Context { override val attachedPipeline: Pipeline? get() = Pipelines.getAttachedPipeline(this) override val documentInfo = DocumentInfo() override val options: ContextOptions = MutableContextOptions() override val loadableLibraries = emptySet() override val localizationTables = emptyMap() override val mediaStorage: ReadOnlyMediaStorage by lazy { MutableMediaStorage(options) } override val sharedSubdocumentsData: SubdocumentsData> = SubdocumentsData( graph = DirectedGraph().visitableOnce, withContexts = mapOf(subdocument to this), ) override val fileSystem: FileSystem by lazy { val workingDirectory = (subdocument as? Subdocument.Resource)?.workingDirectory ?: attachedPipeline?.options?.workingDirectory SimpleFileSystem(workingDirectory) } override fun getFunctionByName(name: String): Function<*>? = libraries .asSequence() .flatMap { it.functions } .find { it.name == name } override fun resolve(call: FunctionCallNode): FunctionCall<*>? { val function = getFunctionByName(call.name) return function?.let { FunctionCall( it, call.arguments, context = this, sourceNode = call, ) } } override fun resolveUnchecked(call: FunctionCallNode): UncheckedFunctionCall<*> = UncheckedFunctionCall(call.name) { resolve(call) } override fun localize( tableName: String, key: String, locale: Locale, ): String { val table = localizationTables[tableName] ?: throw LocalizationTableNotFoundException(tableName) val entries = table[locale] ?: throw LocalizationLocaleNotFoundException(tableName, locale) return entries[key.lowercase()] ?: entries[key] ?: throw LocalizationKeyNotFoundException(tableName, locale, key) } override fun fork(): ScopeContext = throw UnsupportedOperationException("Forking is not supported in BaseContext") } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/ChildContext.kt ================================================ package com.quarkdown.core.context /** * A [Context] that has a parent context, forming a scope tree. * This context can access its parent's properties and inherit them. * @param C type of the parent context */ interface ChildContext : Context { /** * The parent context of this context in the scope tree. */ val parent: C /** * The root context of the scope tree that this context belongs to. */ val root: Context get() = this.lastParentOrNull { true } ?: this /** * @param predicate condition to match * @return the last context (upwards, towards the root, starting from this context) that matches the [predicate], * or `null` if no parent in the scope tree matches the given condition */ fun lastParentOrNull(predicate: (Context) -> Boolean): Context? = when { // This is the last context to match the condition. predicate(this) && !predicate(parent) -> this // The root context matches the condition. parent !is ChildContext<*> && predicate(parent) -> parent // Scan the parent context. else -> (parent as? ChildContext<*>)?.lastParentOrNull(predicate) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/Context.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.ast.attributes.AstAttributes import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.context.subdocument.SubdocumentsData import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.function.Function import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.UncheckedFunctionCall import com.quarkdown.core.function.library.Library import com.quarkdown.core.graph.Graph import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleNotSetException import com.quarkdown.core.localization.LocalizationTables import com.quarkdown.core.media.storage.ReadOnlyMediaStorage import com.quarkdown.core.pipeline.Pipeline /** * Container of information about the current state of the pipeline, shared across the whole pipeline itself. */ interface Context { /** * The Markdown flavor in use. */ val flavor: MarkdownFlavor /** * The pipeline this context is attached to, if it exists. * A context can have up to 1 attached pipeline. * @see com.quarkdown.core.pipeline.Pipelines.getAttachedPipeline * @see com.quarkdown.core.pipeline.Pipelines.attach */ val attachedPipeline: Pipeline? /** * Mutable information about the final document that is being created. */ val documentInfo: DocumentInfo /** * Global properties that affect several behaviors * and that can be altered through function calls. */ val options: ContextOptions /** * Information about the node tree that is being processed by the [attachedPipeline]. */ val attributes: AstAttributes /** * Loaded libraries to look up functions from. */ val libraries: Set /** * External libraries that can be loaded by the user into [libraries]. * These libraries are, for instance, fetched from the library directory (`--libs` option) * and can be loaded via the `.include {name}` function. */ val loadableLibraries: Set /** * Tables that store key-value localization pairs for each supported locale. * Each table is identified by a unique name. * @see localize */ val localizationTables: LocalizationTables /** * Media storage that contains all the media files that are referenced within the document. * For example, if an image node references a local image file "image.png", * the local file needs to be exported to the output directory in order for a browser to look it up. * This storage is used to keep track of all the media files that may need to be exported. * @see com.quarkdown.core.context.hooks.MediaStorerHook */ val mediaStorage: ReadOnlyMediaStorage /** * The subdocument that is being processed by this context. * A subdocument can be the root one or another referenced by a link. */ val subdocument: Subdocument /** * Data about all the subdocuments that are part of the document complex. * This data is shared across all contexts involved in the document complex, * regardless of the sandboxing level. * @see SubdocumentsData for more information */ val sharedSubdocumentsData: SubdocumentsData> /** * The file system relative to this context * which can be used to access files starting from a certain working directory. */ val fileSystem: FileSystem /** * Looks up a function by name. * @param name name of the function to look up, case-sensitive * @return the corresponding function, if it exists */ fun getFunctionByName(name: String): Function<*>? /** * @param call function call node to get a function call from * @return a new function call that [call] references to, with [call]'s arguments, * or `null` if [call] references to an unknown function */ fun resolve(call: FunctionCallNode): FunctionCall<*>? /** * @param call function call node to get a function call from * @return an [UncheckedFunctionCall] that wraps the referenced function call if it has been resolved. * Calling `execute()` on an [UncheckedFunctionCall] whose function isn't resolved throws an exception * @see UncheckedFunctionCall * @see resolve */ fun resolveUnchecked(call: FunctionCallNode): UncheckedFunctionCall<*> /** * Generates a new UUID via [ContextOptions.uuidSupplier]. * @return a new UUID as a string */ fun newUuid(): String = options.uuidSupplier() /** * Localizes a string to this context's language (the locale set in [documentInfo]) by looking up a key in a localization table. * @param tableName name of the localization table, which must exist within [localizationTables] * @param key localization key to look up within the table * @param locale the locale to use for localization, defaulting to the one set in [documentInfo], if any * @return the localized string corresponding to the key in the table, if there is any * @throws com.quarkdown.core.localization.LocaleNotSetException if [locale] is not explicitly set and a locale is not set within [documentInfo] * @throws com.quarkdown.core.localization.LocalizationTableNotFoundException if the table does not exist * @throws com.quarkdown.core.localization.LocalizationKeyNotFoundException if the locale does not exist in the table * @throws com.quarkdown.core.localization.LocalizationKeyNotFoundException if the key does not exist in the table entry for the locale * @see localizationTables */ fun localize( tableName: String, key: String, locale: Locale = documentInfo.locale ?: throw LocaleNotSetException(), ): String /** * @return a new scope context, forked from this context, that shares several base properties */ fun fork(): ScopeContext } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/ContextOptions.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.media.storage.options.MediaStorageOptions private val DEFAULT_SUBDOCUMENT_URL_SUFFIXES = setOf(".qd", ".md") /** * Read-only properties that affect several behaviors of the document generation process, * and that can be altered through function calls through its [MutableContextOptions] implementation. */ interface ContextOptions : MediaStorageOptions { /** * When a [Heading] node has a depth equals or less than this value, a page break is forced. */ val autoPageBreakHeadingMaxDepth: Int /** * Whether automatic identifiers should be generated for elements * that do not have an explicit one. * For example, a heading element (`# Hello world`) automatically generates * an identifier (`hello-world`) that can be referenced by other elements. * @see com.quarkdown.core.ast.attributes.id.IdentifierProvider */ val enableAutomaticIdentifiers: Boolean /** * Whether certain nodes can be aware of their location within the document * in order to display it, for example in headings. * @see com.quarkdown.core.ast.attributes.location.LocationTrackableNode */ val enableLocationAwareness: Boolean /** * The suffixes that, if matched by a link's URL, indicates that the link points to a Quarkdown subdocument. * @see com.quarkdown.core.ast.base.inline.SubdocumentLink */ val subdocumentUrlSuffixes: Set get() = DEFAULT_SUBDOCUMENT_URL_SUFFIXES /** * Supplier of unique identifiers (UUIDs). For instance, UUIDs are generated for anonymous footnotes. */ val uuidSupplier: () -> String } /** * @return whether the [heading] node should force a page break * @see ContextOptions.autoPageBreakHeadingMaxDepth */ fun Context.shouldAutoPageBreak(heading: Heading) = heading.canBreakPage && heading.depth <= this.options.autoPageBreakHeadingMaxDepth /** * @param url URL or file path to check, without any surrounding whitespace or anchors * @return whether the given [url], which may also be a file path, points to a Quarkdown subdocument * depending on its suffix (file extension). * @see com.quarkdown.core.ast.base.inline.SubdocumentLink */ fun Context.isSubdocumentUrl(url: String): Boolean = options.subdocumentUrlSuffixes.any { url.endsWith(it, ignoreCase = true) } /** * Mutable [ContextOptions] implementation. */ data class MutableContextOptions( override var autoPageBreakHeadingMaxDepth: Int = 1, override var enableAutomaticIdentifiers: Boolean = true, override var enableLocationAwareness: Boolean = true, override var subdocumentUrlSuffixes: Set = DEFAULT_SUBDOCUMENT_URL_SUFFIXES, override var uuidSupplier: () -> String = { java.util.UUID .randomUUID() .toString() }, override var enableRemoteMediaStorage: Boolean = false, override var enableLocalMediaStorage: Boolean = false, ) : ContextOptions { /** * Mutates this instance by merging the current media storage rules with the given [options]. * An option is overridden and merged only if its value from [options] is set, i.e. not `null`. * @param options options to merge this instance with */ fun mergeMediaStorageOptions(options: MediaStorageOptions) { options.enableRemoteMediaStorage?.let { enableRemoteMediaStorage = it } options.enableLocalMediaStorage?.let { enableLocalMediaStorage = it } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/MutableContext.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.LibraryRegistrant import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.localization.MutableLocalizationTables import com.quarkdown.core.media.storage.MutableMediaStorage /** * A mutable [Context] implementation, which allows registering new data to be looked up later. * @param flavor Markdown flavor used for this pipeline. It specifies how to produce the needed components * @param libraries loaded libraries to look up functions from * @param attributes attributes of the node tree, which can be manipulated on demand * @param subdocument the subdocument this context is processing */ open class MutableContext( flavor: MarkdownFlavor = QuarkdownFlavor, libraries: Set = emptySet(), loadableLibraries: Set = emptySet(), subdocument: Subdocument = Subdocument.Root, override val attributes: MutableAstAttributes = MutableAstAttributes(), override val options: MutableContextOptions = MutableContextOptions(), ) : BaseContext(attributes, flavor, libraries, subdocument) { override val libraries: MutableSet = super.libraries.toMutableSet() override var documentInfo: DocumentInfo = super.documentInfo override val loadableLibraries: MutableSet = (super.loadableLibraries + loadableLibraries).toMutableSet() override val localizationTables: MutableLocalizationTables = super.localizationTables.toMutableMap() override val mediaStorage: MutableMediaStorage get() = super.mediaStorage as MutableMediaStorage override var sharedSubdocumentsData = super.sharedSubdocumentsData // Prevents function calls from being enqueued. private var lockFunctionCallEnqueuing = false /** * Enqueues a new [FunctionCallNode], which is executed in the next stage of the pipeline. * Nothing happens if enqueuing is locked via [lockFunctionCallEnqueuing]. * @param functionCall function call to register */ open fun register(functionCall: FunctionCallNode) { if (!lockFunctionCallEnqueuing) { attributes.functionCalls += functionCall } } // This override makes sure the same function call is dequeued from the execution queue // after its execution is completed, so that it won't be accidentally executed again. // A double execution may happen if it's in the execution queue AND another function evaluates it. override fun resolve(call: FunctionCallNode): FunctionCall<*>? = super.resolve(call)?.also { it.onComplete = { attributes.functionCalls.remove(call) } } override fun fork(): ScopeContext = ScopeContext(parent = this) /** * Loads a loadable library by name and registers it in the context. * After a successful load, the library is added to [libraries], with its [Library.onLoad] action executed. * @param name name of the library to load, case-sensitive * @return the loaded library, if it exists, paired with the value returned by its [Library.onLoad] action if it exists */ fun loadLibrary(name: String): Pair?>? = loadableLibraries.find { it.name == name }?.let { library -> library to LibraryRegistrant(this).register(library) } /** * Returns a copy of the queue containing registered function calls and clears the original one. * @return all the registered function call nodes until now */ fun dequeueAllFunctionCalls(): List = attributes.functionCalls.toList().also { attributes.functionCalls.clear() } /** * Performs an action locking the enqueuing of function calls. * This causes [register] to do nothing until the action is completed. * Any function call enqueued during the action is discarded and won't be expanded by the pipeline. * @param block action to perform * @return the result of the action */ fun lockFunctionCallEnqueuing(block: MutableContext.() -> T): T { lockFunctionCallEnqueuing = true return block().also { lockFunctionCallEnqueuing = false } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/ScopeContext.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.context.file.FileSystem /** * A context that is the result of a fork from an original parent [Context]. * All properties are inherited from it, but not all, such as libraries, are shared mutably. * @param parent context this scope was forked from * @param fileSystem file system to use in this context. Overrides the attached pipeline's file system if provided * @see SubdocumentContext to see what's inherited from the parent context */ open class ScopeContext( parent: MutableContext, fileSystem: FileSystem = parent.fileSystem, ) : SubdocumentContext( parent = parent, subdocument = parent.subdocument, _fileSystem = fileSystem, ) { // A scope context shares the parent's document info. override var documentInfo by parent::documentInfo override val attributes by parent::attributes // Media registered in a scope is pushed to the parent's media storage. override val mediaStorage by parent::mediaStorage } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/SharedContext.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.function.Function /** * A context that shares all of its properties with its parent [MutableContext]. * This is useful when a context needs to be forked, for example to update its [fileSystem], but still its state mutably. * @param parent context this shared context was forked from * @param fileSystem file system to use in this context */ open class SharedContext( override val parent: MutableContext, override val fileSystem: FileSystem = parent.fileSystem, ) : MutableContext( flavor = parent.flavor, libraries = emptySet(), subdocument = parent.subdocument, ), ChildContext { override val attachedPipeline by parent::attachedPipeline override var documentInfo by parent::documentInfo override val libraries by parent::libraries override val options by parent::options override val attributes by parent::attributes override val loadableLibraries by parent::loadableLibraries override val localizationTables by parent::localizationTables override val mediaStorage by parent::mediaStorage override var sharedSubdocumentsData by parent::sharedSubdocumentsData override fun getFunctionByName(name: String): Function<*>? = parent.getFunctionByName(name) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/SubdocumentContext.kt ================================================ package com.quarkdown.core.context import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.function.Function import com.quarkdown.core.pipeline.Pipeline /** * A context that is the result of a fork from an original parent [Context]. * All properties are inherited from it, but not all, such as libraries, are shared mutably. * * This is mainly designed to be forked for subdocuments, where a new context is needed to process them. * Each subdocument context shares most properties with its parent context, but maintains its own state for certain aspects like document info. * * [ScopeContext] inherits from this class, and shares the parent's document info instead. * * @param parent context this scope was forked from * @param subdocument the subdocument this context is processing * @param _fileSystem file system to use in this context. Overrides the attached pipeline's file system if provided */ open class SubdocumentContext( override val parent: MutableContext, subdocument: Subdocument, private val _fileSystem: FileSystem? = null, ) : MutableContext( flavor = parent.flavor, libraries = emptySet(), subdocument = subdocument, ), ChildContext { override val attachedPipeline: Pipeline? get() = super.attachedPipeline ?: parent.attachedPipeline // A subdocument inherits the parent's document info, but changes to it are local to this subdocument. override var documentInfo: DocumentInfo = parent.documentInfo override val options by parent::options override val loadableLibraries by parent::loadableLibraries override val localizationTables by parent::localizationTables override var sharedSubdocumentsData by parent::sharedSubdocumentsData /** * If a file system is provided during construction, it is used. * Otherwise, [BaseContext]'s implementation is used, which retrieves it from [attachedPipeline]. */ override val fileSystem: FileSystem get() = _fileSystem ?: super.fileSystem /** * If no matching function is found among this [SubdocumentContext]'s own [libraries], * [parent]'s libraries are scanned. * @see Context.getFunctionByName */ override fun getFunctionByName(name: String): Function<*>? = super.getFunctionByName(name) ?: parent.getFunctionByName(name) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/file/FileSystem.kt ================================================ package com.quarkdown.core.context.file import com.quarkdown.core.util.IOUtils import java.io.File import java.nio.file.Path import kotlin.io.path.absolute /** * A file system abstraction which can retrieve files, * either absolutely or relative to a working directory. */ interface FileSystem { /** * The working directory of this file system. * If not `null`, [resolve] will be able to resolve relative paths * from this directory. */ val workingDirectory: File? /** * The root file system that originated this one via [branch] calls. * If `null`, this file system is the root. */ val root: FileSystem? /** * Whether this file system is the root one. */ val isRoot: Boolean get() = root == null /** * Resolves a local file path, either absolutely or relatively from [workingDirectory]. * This does not perform any check for file existence. * @param path absolute or relative file path to resolve * @return the resolved file */ fun resolve(path: String): File /** * Creates a new [FileSystem] branched from this one, with the given [workingDirectory]. * * The [root] of the new file system is set to this file system if it has no root, * or to this file system's root otherwise. * * @param workingDirectory new working directory * @return the branched file system */ fun branch(workingDirectory: File?): FileSystem /** * Computes the relative path from this file system's [workingDirectory] to [other]'s. * @param other the target file system * @return the relative path from this working directory to the other, * or `null` if either working directory is `null` */ fun relativePathTo(other: FileSystem): Path? } /** * A simple [FileSystem] implementation that resolves paths * based on an optional working directory. */ internal data class SimpleFileSystem( override val workingDirectory: File? = null, override val root: FileSystem? = null, ) : FileSystem { override fun branch(workingDirectory: File?): FileSystem = SimpleFileSystem(workingDirectory, root ?: this) override fun resolve(path: String): File = IOUtils.resolvePath(path, workingDirectory) override fun relativePathTo(other: FileSystem): Path? { val from = this.workingDirectory?.toPath()?.absolute() ?: return null val to = other.workingDirectory?.toPath()?.absolute() ?: return null return try { from.relativize(to) } catch (_: IllegalArgumentException) { null } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/LinkUrlResolverHook.kt ================================================ package com.quarkdown.core.context.hooks import com.quarkdown.core.ast.attributes.link.setResolvedUrl import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.util.isURL import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.invariantSeparatorsPathString /** * Hook that resolves relative link paths based on their file system. * * If a link uses a relative path and its file system * is different from the [context]'s file system, * the path is resolved relative to the context's file system. * * This is mainly applied to images. * * @param context root context to use for resolution * @see com.quarkdown.core.ast.attributes.link.ResolvedLinkUrlProperty */ class LinkUrlResolverHook( private val context: MutableContext, ) : AstIteratorHook { /** * Resolves the URL of a [link] if it's a relative path * and its file system is different from the [context]'s file system. * * @param link link node to resolve */ private fun resolve(link: LinkNode) { val fileSystem = link.fileSystem if (fileSystem == null || fileSystem.isRoot) return // No need to resolve paths. if (link.url.isURL || Path(link.url).isAbsolute) return // Not a relative path. val resolved: Path? = context.fileSystem .relativePathTo(fileSystem) ?.resolve(link.url) ?.normalize() resolved?.let { link.setResolvedUrl(context, it.invariantSeparatorsPathString) } } override fun attach(iterator: ObservableAstIterator) { iterator.on { resolve(it.link) } iterator.on { resolve(it) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/MediaStorerHook.kt ================================================ package com.quarkdown.core.context.hooks import com.quarkdown.core.ast.attributes.link.getResolvedUrl import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.media.StoredMediaProperty import com.quarkdown.core.context.MutableContext import com.quarkdown.core.log.Log import com.quarkdown.core.media.storage.StoredMedia /** * Hook that, when a node containing information about media is found, * registers it in the media [storage]. * A media storage is a temporary lookup table that maps media to their paths, so that they can be resolved later. * @param storage media storage where media are registered */ class MediaStorerHook( private val context: MutableContext, ) : AstIteratorHook { /** * Registers a media contained within a link into the media storage * and attaches the new media to the node's extra attributes. * * [getResolvedUrl] is used rather than [LinkNode.url] in case a different URL was set by [LinkUrlResolverHook]. * * @param link the link node containing the media to register. * It is also the node to attach the [StoredMediaProperty] to, into [com.quarkdown.core.ast.attributes.AstAttributes.properties] */ private fun register(link: LinkNode) { val media: StoredMedia? = try { context.mediaStorage.register( link.getResolvedUrl(context), context.fileSystem.workingDirectory, ) } catch (_: IllegalArgumentException) { // If the media cannot be resolved, it is ignored and not stored. Log.warn("Media cannot be resolved: ${link.url}") return } // The stored media is attached to the node's extra attributes. media ?.let(::StoredMediaProperty) ?.also { Log.debug("Registered media: ${link.url} -> ${it.value}") } ?.let { context.attributes.of(link) += it } } override fun attach(iterator: ObservableAstIterator) { // Images are instantly registered. iterator.on { register(it.link) } // Reference images are registered upon resolution, // i.e. when a definition that matches the reference is found. iterator.on { it.link.onResolve.add(::register) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/SubdocumentRegistrationHook.kt ================================================ package com.quarkdown.core.context.hooks import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.setSubdocument import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.subdocument.findResourceByPath import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.log.Log /** * Hook that registers [Subdocument]s in the subdocument graph of [context]. * A subdocument is a separate document file that is referenced by a link from the main document or another subdocument. * @param context the context to attach this hook to */ class SubdocumentRegistrationHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { iterator.on { link -> val fileSystem = link.fileSystem ?: context.fileSystem val file = fileSystem.resolve(path = link.url) if (!file.exists()) { Log.warn("Cannot find subdocument referenced by a link: $file") return@on } val path = file.canonicalFile.absolutePath // Reuse an already-registered subdocument to avoid redundant file I/O. val subdocument = context.subdocumentGraph.findResourceByPath(path) ?: Subdocument.Resource( name = file.nameWithoutExtension, path = path, workingDirectory = file.parentFile.canonicalFile, content = file.readText(), ) link.setSubdocument(context, subdocument) context.subdocumentGraph = context.subdocumentGraph .addVertexAndEdge( vertex = subdocument, edgeFrom = context.subdocument, edgeTo = subdocument, ) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/TableOfContentsGeneratorHook.kt ================================================ package com.quarkdown.core.context.hooks import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.toc.TableOfContents /** * Hook that allows the generation of a [TableOfContents] by iterating through [Heading]s. * The [TableOfContents] is stored in the [context]'s [MutableContext.attributes] at the end of the traversal. */ class TableOfContentsGeneratorHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { val headings = iterator.collect { !it.excludeFromTableOfContents } // Generation. iterator.onFinished { context.attributes.tableOfContents = TableOfContents.generate(headings.asSequence()) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/location/LocationAwareLabelStorerHook.kt ================================================ package com.quarkdown.core.context.hooks.location import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.SectionLocation import com.quarkdown.core.ast.attributes.location.getLocation import com.quarkdown.core.ast.attributes.location.setLocationLabel import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat /** * Hook that, if executed after [LocationAwarenessHook] has populated the location data for each location-trackable node, * assigns identifiers to elements based on their location in the document and the current [NumberingFormat] which dictates the 'accuracy' or threshold. * * For example, given a document with the following structure: * * ``` * # Heading 1 * * ![Figure](image.png) * * ## Heading 2 * * ![Another figure](another-image.png) * * # Heading 3 * * ![Yet another figure](yet-another-image.png) * ``` * * - If the numbering format for figures is `1.1`, the first figure will be labeled as `1.1`, * the second as `1.2`, and the third as `2.1`. * * - If the numbering format for figures is `1`, the first figure will be labeled as `1`, * the second as `2`, and the third as `3`. * * @see com.quarkdown.core.ast.attributes.location * @see LocationTrackableNode */ class LocationAwareLabelStorerHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { if (!context.options.enableLocationAwareness) return updateHeadingLabels(iterator) updateLabels>(DocumentNumbering::figures, iterator) updateLabels(DocumentNumbering::tables, iterator) updateLabels(DocumentNumbering::math, iterator) updateLabels(DocumentNumbering::codeBlocks, iterator) // Updates the labels of Numbered nodes, which are grouped by their key. // 'extra' numbering formats can be set via the `.numbering` function. context.documentInfo.numberingOrDefault?.extra?.forEach { (name, numbering) -> updateLabels({ numbering }, iterator, filter = { it.key == name }) } } /** * Updates labels of [Heading] nodes based on their location in the document and the heading numbering format. * * The location, stored by [LocationAwarenessHook], is applied as a label as long as * its accuracy does not exceed that of the heading numbering format. * @param iterator iterator to attach the hook to */ private fun updateHeadingLabels(iterator: ObservableAstIterator) { val format = context.documentInfo.numberingOrDefault?.headings ?: return iterator.on { node -> val location = node.getLocation(context) ?: return@on if (location.depth <= format.accuracy) { node.setLocationLabel(context, format.format(location)) } } } /** * Updates labels of elements of type [T] based on their location in the document and the given numbering format. * * Let, for example, the numbering format for figures be `1.1` (= accuracy 2). * Then, the location at accuracy 1 is retrieved (`accuracy - 1`) and the last level is reserved for the element's own index, * incremented each time an element is found at the same location. * * @param formatSupplier numbering format used to generate the labels for this type of element, * supplied by [context]'s document numbering settings * @param iterator iterator to attach the hook to * @param filter condition to satisfy in order to update the label of each element and increment the counter * @param T type of elements to update the labels of */ private inline fun updateLabels( formatSupplier: (DocumentNumbering) -> NumberingFormat?, iterator: ObservableAstIterator, crossinline filter: (T) -> Boolean = { it.canTrackLocation }, ) { // Gets the needed numbering format from the global numbering settings. val format = formatSupplier(context.documentInfo.numberingOrDefault ?: return) if (format == null || format.isNonCounting) return // Stores the number of elements encountered at each location. val countAtLocation = mutableMapOf() // Accuracy is the number of levels that the location should be trimmed to. // For example, if the accuracy is 2, the location `1.2.3.4` will be trimmed to `1.2`. // The last numbering symbol is reserved for the element's own ID. val accuracy = format.accuracy - 1 iterator.on { node -> if (!filter(node)) return@on // A location has to be already stored for the given node (see LocationAwarenessHook). val location = node.getLocation(context) ?: return@on // The location of the element is trimmed to the desired accuracy. val trimmedLocation = SectionLocation( when { // If the location has more nested levels than the accuracy, it is trimmed. // e.g. Location: `2.1.0.1`, Accuracy: 2 -> Result: `2.1` location.depth > accuracy -> location.levels.take(accuracy) // If the location has less nested levels than the accuracy, it is padded with zeroes. // e.g. Location: `2.1`, Accuracy: 4 -> Result: `2.1.0.0` location.depth < accuracy -> location.levels + Array(accuracy - location.levels.size) { 0 } // Location levels and accuracy match. else -> location.levels }, ) // The number of elements encountered at the trimmed location is updated. val count = (countAtLocation[trimmedLocation] ?: 0) + 1 countAtLocation[trimmedLocation] = count // Given the trimmed location of the element, // its index is appended to it in order to form the final label of the element. val label = location.copy(levels = trimmedLocation.levels + count) // The stringified label is stored. node.setLocationLabel(context, format.format(label)) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/location/LocationAwarenessHook.kt ================================================ package com.quarkdown.core.context.hooks.location import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.SectionLocation import com.quarkdown.core.ast.attributes.location.setLocation import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext /** * Hook that stores the location of each [LocationTrackableNode] in the document. * @see LocationTrackableNode * @see com.quarkdown.core.ast.attributes.location */ class LocationAwarenessHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { // Stores the current section location. // The key is the depth of the last heading found; // the value is the index of the section. val location = mutableMapOf() // Note: the two following hooks are executed in 'parallel'. // When a heading is found, the current location is updated. // Example: // current location: [] // # A => current location: [1] // ## A.A => current location: [1, 1] // # B => current location: [2] // ## B.A => current location: [2, 1] // ### B.A.A => current location: [2, 1, 1] // ### B.A.B => current location: [2, 1, 2] // # C => current location: [3] // ### C.0.A => current location: [3, 0, 1] iterator.on { heading -> if (!heading.canTrackLocation) return@on // 'Decorative' headings are not assigned a location and are not counted. location[heading.depth] = (location[heading.depth] ?: 0) + 1 location.entries.removeIf { it.key > heading.depth } // Gap filler: e.g. if an H1 is followed by an H3, a mock H2 with value '0' is automatically added. for (i in 1 until heading.depth) { if (location[i] == null) { location[i] = 0 } } } // The current location, loaded by the previous hook, is associated with each node that requests its location to be tracked. iterator.on { trackable -> if (!trackable.canTrackLocation) return@on val locationData = location .asSequence() .sortedBy { it.key } .map { it.value } // Registration of the location. trackable.setLocation(context, SectionLocation(locationData.toList())) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/location/NumberedEvaluatorHook.kt ================================================ package com.quarkdown.core.context.hooks.location import com.quarkdown.core.ast.attributes.location.getLocationLabel import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.context.Context import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.error.asNode /** * Hook that evaluates the [Numbered] nodes in the document. * If the evaluation fails, it attaches an error box, as in a regular function call expansion. * This needs to be attached **after** the [LocationAwareLabelStorerHook] has populated the location labels. * @param context context to retrieve the location label from * @see Numbered to understand why it needs evaluation */ class NumberedEvaluatorHook( private val context: Context, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { iterator.on { node -> val label = node.getLocationLabel(context) ?: "" node.children = try { node.childrenSupplier(label) } catch (e: PipelineException) { // Set an error box as the node's child if the evaluation fails. listOf(e.asNode(context)) } } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/presence/ThirdPartyPresenceHook.kt ================================================ package com.quarkdown.core.context.hooks.presence import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.presence.markCodePresence import com.quarkdown.core.ast.attributes.presence.markMathPresence import com.quarkdown.core.ast.attributes.presence.markMermaidDiagramPresence import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.context.MutableContext // Hooks that mark the presence of third-party elements in the document, // in order to conditionally load third-party libraries in the final artifact. /** * Hook that marks the presence of code elements in the [context]'s attributes * if at least one [Code] block is present in the document. * @see com.quarkdown.core.ast.attributes.presence.CodePresenceProperty */ class CodePresenceHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { iterator.on { context.attributes.markCodePresence() } } } /** * Hook that marks the presence of math elements in the [context]'s attributes * if at least one [Math] or [MathSpan] block is present in the document. * @see com.quarkdown.core.ast.attributes.presence.MathPresenceProperty */ class MathPresenceHook( private val context: MutableContext, ) : AstIteratorHook { private val action: (Node) -> Unit get() = { context.attributes.markMathPresence() } override fun attach(iterator: ObservableAstIterator) { iterator .on(action) .on(action) } } /** * Hook that marks the presence of code elements in the [context]'s attributes * if at least one [Code] block is present in the document. * @see com.quarkdown.core.ast.attributes.presence.MermaidDiagramPresenceProperty */ class MermaidDiagramPresenceHook( private val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { iterator.on { context.attributes.markMermaidDiagramPresence() } iterator.on { context.attributes.markMermaidDiagramPresence() } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/BibliographyCitationResolverHook.kt ================================================ package com.quarkdown.core.context.hooks.reference import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.bibliography.Bibliography import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.context.MutableContext /** * Hook that associates bibliography entries to each [BibliographyCitation] * that can be linked to entries of a [Bibliography] * within a [BibliographyView]. */ class BibliographyCitationResolverHook( context: MutableContext, ) : ReferenceDefinitionResolverHook, BibliographyView>>(context) { override fun collectReferences(iterator: ObservableAstIterator) = iterator.collectAll() override fun collectDefinitions(iterator: ObservableAstIterator) = iterator.collectAll() override fun findDefinitionPair( reference: BibliographyCitation, definitions: List, index: Int, ): Pair, BibliographyView>>? = definitions .firstNotNullOfOrNull { bibliography -> val entries = reference.citationKeys.map { key -> bibliography.bibliography.entries[key] ?: return@firstNotNullOfOrNull null } bibliography to (entries to bibliography) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/CrossReferenceResolverHook.kt ================================================ package com.quarkdown.core.context.hooks.reference import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.context.MutableContext /** * A [ReferenceDefinitionResolverHook] that associates a [CrossReferenceableNode] to each [CrossReference] by means of matching IDs. */ class CrossReferenceResolverHook( context: MutableContext, ) : ReferenceDefinitionResolverHook(context) { override fun collectReferences(iterator: ObservableAstIterator) = iterator.collectAll() override fun collectDefinitions(iterator: ObservableAstIterator) = iterator.collectAll() override fun findDefinitionPair( reference: CrossReference, definitions: List, index: Int, ): Pair? = definitions .find { reference.referenceId == it.referenceId } ?.let { it to it } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/FootnoteResolverHook.kt ================================================ package com.quarkdown.core.context.hooks.reference import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.setIndex import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext /** * Hook that associates a [FootnoteDefinition] to each [ReferenceFootnote]. */ class FootnoteResolverHook( context: MutableContext, ) : ReferenceDefinitionResolverHook(context) { override fun collectReferences(iterator: ObservableAstIterator) = iterator.collectAll() override fun collectDefinitions(iterator: ObservableAstIterator) = iterator.collectAll() /** * Zips the references with their index in the list of references, grouped by their label. * * Example: * - `firstlabel` * - `secondlabel` * - `secondlabel` * - `firstlabel` * - `thirdlabel` * * Will assign the following indices to each: * - 0 * - 1 * - 1 * - 0 * - 2 */ override fun indexReferences(references: List>) = references.groupBy { it.reference.label }.let { grouped -> // Assigns a stable index to each unique label. val labelToIndex = grouped.keys.withIndex().associate { (index, label) -> label to index } grouped.flatMap { (label, refs) -> val index = labelToIndex.getValue(label) refs.map { IndexedValue(index, it) } } } override fun findDefinitionPair( reference: ReferenceFootnote, definitions: List, index: Int, ): Pair? = definitions .find { it.label == reference.label } .also { it?.setIndex(context, index) } ?.let { it to it } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/LinkDefinitionResolverHook.kt ================================================ package com.quarkdown.core.context.hooks.reference import com.quarkdown.core.ast.attributes.link.getResolvedUrl import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.util.node.toPlainText /** * Hook that associates a [LinkDefinition] to each [ReferenceLink], * producing a resolved [Link] node that can be retrieved via * [com.quarkdown.core.ast.attributes.reference.getDefinition]. * * When a match is found, [ReferenceLink.onResolve] callbacks are triggered, * which are used by [com.quarkdown.core.context.hooks.MediaStorerHook] * to register media for reference images. */ class LinkDefinitionResolverHook( context: MutableContext, ) : ReferenceDefinitionResolverHook(context) { override fun collectReferences(iterator: ObservableAstIterator): List> { val references = mutableListOf>() iterator.on { references += it } iterator.on { references += it.link } return references } override fun collectDefinitions(iterator: ObservableAstIterator) = iterator.collectAll() override fun findDefinitionPair( reference: ReferenceLink, definitions: List, index: Int, ): Pair? = definitions .find { it.label.toPlainText() == reference.referenceLabel.toPlainText() } ?.let { definition -> val link = Link(reference.label, definition.getResolvedUrl(context), definition.title, definition.fileSystem) reference.onResolve.forEach { action -> action(link) } definition to link } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/hooks/reference/ReferenceDefinitionResolverHook.kt ================================================ package com.quarkdown.core.context.hooks.reference import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.ReferenceNode import com.quarkdown.core.ast.attributes.reference.setDefinition import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext /** * Extensible hook that attempts associating a definition to each [ReferenceNode]. * For example, footnotes, bibliography citations, etc. can be resolved to their definitions. * @param R the type of the reference element (for example [com.quarkdown.core.ast.base.inline.ReferenceFootnote]) * @param DN the type of the node that carries the definition (for example [com.quarkdown.core.ast.base.block.FootnoteDefinition]) * @param D the type of the definition to be ultimately associated with the reference * @see ReferenceNode */ abstract class ReferenceDefinitionResolverHook( protected val context: MutableContext, ) : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { val references = collectReferences(iterator) val definitions = collectDefinitions(iterator) iterator.onFinished { indexReferences(references).forEach { (index, reference) -> val definition = findDefinitionPair(reference.reference, definitions, index) definition?.let { reference.setDefinition(context, transformDefinitionPair(it)) } } } } /** * @return all [ReferenceNode]s of the desired expected type from the AST. */ protected abstract fun collectReferences(iterator: ObservableAstIterator): List> /** * @return all definition nodes of the desired expected type from the AST. */ protected abstract fun collectDefinitions(iterator: ObservableAstIterator): List /** * Assigns an index to each reference by their order in the list. * The result indices will be input to [findDefinitionPair]. * @param references the list of references to index * @return an iterable of indexed values, where each value is a pair of index and reference node */ protected open fun indexReferences(references: List>): Iterable>> = references.withIndex() /** * Given a reference and a list of definitions to search in, looks for a matching definition. * @param reference the reference to find the definition for * @param definitions the list of all definitions to search in * @param index the index of the reference among all references of the same type, obtained from [indexReferences] * @return a pair of a definition node and the definition itself, if found */ protected abstract fun findDefinitionPair( reference: R, definitions: List, index: Int, ): Pair? /** * Transforms the definition pair into the final definition to be associated with the reference. * This is useful if the definition node and the definition itself are not of the same type. * @param definition the pair of a definition node and the definition itself * @return the definition to be associated with the reference */ protected open fun transformDefinitionPair(definition: Pair): D = definition.second } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/localization/ContextLocalization.kt ================================================ package com.quarkdown.core.context.localization import com.quarkdown.core.context.Context import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.localization.LocalizationException /** * Name of the stdlib localization table. * Not that the library might not always be present, * hence it is suggested to use it with [localizeOrNull]. * @see [com.quarkdown.core.context.Context.localize] */ private const val STDLIB_LOCALIZATION_TABLE_NAME = "std" /** * Default locale to use as fallback via [localizeOrDefault] if a localization key is not found. * Defaults to English (en). */ private val DEFAULT_LOCALE by lazy { LocaleLoader.SYSTEM.fromTag("en") } /** * Localizes a key from a table. * @param key key to localize * @param tableName name of the table. Defaults to the stdlib table (`std`). * @param locale the locale to localize for, defaulting to the one set in the context's metadata, if any * @return the localized string if the [key] exists in the table, `null` otherwise * @see com.quarkdown.core.context.Context.localize */ fun Context.localizeOrNull( tableName: String = STDLIB_LOCALIZATION_TABLE_NAME, key: String, locale: Locale? = null, ): String? = try { when (locale) { null -> localize(tableName, key) else -> localize(tableName, key, locale) } } catch (_: LocalizationException) { null } /** * Localizes a key from the stdlib table. If the key is not found in the context's locale, * it falls back to the default locale (English). * @param tableName name of the table. Defaults to the stdlib table (`std`). * @param key localization key * @return the localized string (preferably in the context's locale, or in the fallback locale otherwise) * if the [key] exists in the `std` table, `null` otherwise */ fun Context.localizeOrDefault( tableName: String = STDLIB_LOCALIZATION_TABLE_NAME, key: String, ): String? = localizeOrNull(tableName, key) ?: localizeOrNull(tableName, key, locale = DEFAULT_LOCALE) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/subdocument/SubdocumentsData.kt ================================================ package com.quarkdown.core.context.subdocument import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.graph.Graph import com.quarkdown.core.graph.VisitableOnceGraph /** * Immutable container of information about the subdocuments that are part of a document complex. * A subdocument is a separate document file that can be rendered independently, * and is referenced by a link from the main document or another subdocument. * * This data is shared, meaning that all contexts involved in the document complex * have access to the same subdocument graph and information, regardless of the sandboxing level. * For instance, [com.quarkdown.core.context.SubdocumentContext] has strong isolation from the main document context, * but can still access and modify the shared subdocument data. * * @param graph directed graph of the subdocuments that are part of the document complex * @param withContexts mapping of each subdocument in the graph to the context it is processed with */ data class SubdocumentsData>( val graph: G, val withContexts: Map, ) { /** * Adds a new subdocument and its context to the current data, * returning a new instance with the updated mapping. * * Note that this does not modify the [graph]; the new subdocument * must already be part of it. * * @param subdocument the subdocument to add * @param context the context the subdocument is processed with * @return a new instance of [SubdocumentsData] with the updated mapping */ fun addContext( subdocument: Subdocument, context: Context, ): SubdocumentsData = this.copy(withContexts = this.withContexts + (subdocument to context)) } /** * The directed graph of subdocuments that are part of the document complex. * @see SubdocumentsData.graph */ val Context.subdocumentGraph: Graph get() = this.sharedSubdocumentsData.graph /** * The directed graph of subdocuments that are part of the document complex. * @see SubdocumentsData.graph */ var MutableContext.subdocumentGraph: VisitableOnceGraph get() = this.sharedSubdocumentsData.graph set(value) { this.sharedSubdocumentsData = this.sharedSubdocumentsData.copy(graph = value) } /** * Finds a [Subdocument.Resource] vertex in the graph by its absolute [path]. * @return the matching resource, or `null` if not found */ fun Graph.findResourceByPath(path: String): Subdocument.Resource? = vertices .asSequence() .filterIsInstance() .find { it.path == path } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/context/toc/TableOfContents.kt ================================================ package com.quarkdown.core.context.toc import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.attributes.id.Identifiable import com.quarkdown.core.ast.base.block.Heading /** * A summary of the document's structure. Each item links to a section. * @param items root sections in the document */ data class TableOfContents( val items: List, ) { /** * An item in the table of contents, usually associated to a section of the document. * @param text text of the item * @param target element the item links to * @param depth depth of the item. * This does not necessarily correspond to the depth of this item in the stack of items, * but rather represents the importance of the item. * @param subItems nested items */ data class Item( val text: InlineContent, val target: Identifiable, val depth: Int, val subItems: List = emptyList(), ) { /** * Shorthand constructor for creating an item from a heading. * @param heading heading to create the item from * @param subItems nested items */ constructor(heading: Heading, subItems: List = emptyList()) : this( heading.text, heading, heading.depth, subItems, ) } companion object { /** * Generates a table of contents from a flat sequence of headings, based on their depth. * * Example: * * ``` * H1 ABC * H2 DEF * H2 GHI * H3 JKL * H2 MNO * H1 PQR * ``` * Should generate: * ``` * - ABC * - DEF * - GHI * - JKL * - MNO * - PQR * ``` * * @param headings flat sequence of headings * @return the generated table of contents */ fun generate(headings: Sequence): TableOfContents { /** * Helper function to add a heading into the correct place in the hierarchy. * @param hierarchy the current hierarchy * @param item the item to add */ fun addItemToHierarchy( hierarchy: List, item: Item, ): List { if (hierarchy.isEmpty()) { return hierarchy + item } val parent = hierarchy.last() // If the new item's depth is less than or equal to the parent's depth, // it should be a sibling, not a child. if (item.depth <= parent.depth) { return hierarchy + item } // Otherwise, add as a descendant of the parent. val newSubItems = addItemToHierarchy(parent.subItems, item) return hierarchy.dropLast(1) + parent.copy(subItems = newSubItems) } // Fold through headings to build the hierarchy via an accumulator. val result = headings .fold(emptyList()) { accumulator, heading -> addItemToHierarchy(accumulator, Item(heading)) } return TableOfContents(result) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/DocumentAuthor.kt ================================================ package com.quarkdown.core.document /** * An author of a document. * @param name author's name * @param info additional information about the author (e.g. email, website) */ data class DocumentAuthor( val name: String, val info: Map = emptyMap(), ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/DocumentInfo.kt ================================================ package com.quarkdown.core.document import com.quarkdown.amber.annotations.NestedData import com.quarkdown.core.document.layout.DocumentLayoutInfo import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.tex.TexInfo import com.quarkdown.core.localization.Locale /** * Immutable information about the document. * This data is updated by library functions `.docname`, `.docauthor`, etc., by overwriting [com.quarkdown.core.context.MutableContext.documentInfo]. * @param type type of the document * @param name name of the document, if specified * @param description description of the document, if specified * @param authors authors of the document, if specified * @param keywords keywords of the document, if specified * @param locale language of the document * @param numbering formats to apply to element numbering across the document * @param theme theme of the document, if specified * @param pageFormat format of the pages of the document */ @NestedData data class DocumentInfo( val type: DocumentType = DocumentType.PLAIN, val name: String? = null, val description: String? = null, val authors: List = emptyList(), val keywords: List = emptyList(), val locale: Locale? = null, val numbering: DocumentNumbering? = null, val theme: DocumentTheme? = null, val tex: TexInfo = TexInfo(), val layout: DocumentLayoutInfo = DocumentLayoutInfo(), ) { /** * The numbering formats of the document if set by the user, * otherwise the default numbering of the document [type] (which may also be `null`). * @see DocumentType.defaultNumbering */ val numberingOrDefault: DocumentNumbering? get() = numbering ?: type.defaultNumbering } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/DocumentTheme.kt ================================================ package com.quarkdown.core.document /** * The theme of a document. A theme is defined by different components, hence allowing different combinations. * Components can also be not specified by setting them to `null`, and are hence ignored. * @param color color scheme component (refers to an internal resource in `resources/render/theme/color`) * @param layout layout format component (refers to an internal resource in `resources/render/theme/layout`) */ data class DocumentTheme( val color: String?, val layout: String?, ) { /** * @return whether this theme has at least one component specified */ val hasComponent: Boolean get() = color != null || layout != null } /** * Given [this] theme with nullable components, merges it with a default theme in order to fill in the missing components. * If [this] is `null` itself, the default theme is returned. * @param default default theme * @return a new theme with all components filled in, either from [this] (higher priority) or [default] (fill) */ fun DocumentTheme?.orDefault(default: DocumentTheme) = DocumentTheme( color = this?.color ?: default.color, layout = this?.layout ?: default.layout, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/DocumentType.kt ================================================ package com.quarkdown.core.document import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.page.PageOrientation import com.quarkdown.core.document.layout.page.PageSizeFormat import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat /** * Type of produced document, which affects its post-rendering stage. * @param preferredOrientation the preferred orientation of the document, to apply if not overridden by the user * @param defaultPageFormat the default page format to apply, if not overridden by the user * @param defaultNumbering the default numbering formats to apply, if not overridden by the user */ enum class DocumentType( val preferredOrientation: PageOrientation, val defaultPageFormat: PageFormatInfo? = null, val defaultNumbering: DocumentNumbering? = null, ) { /** * A document whose rendered content is not altered by the post-rendering stage. * Plain Markdown is often used as plain (e.g. READMEs). */ PLAIN( PageOrientation.PORTRAIT, defaultNumbering = DocumentNumbering( math = NumberingFormat.fromString("(1)"), ), ), /** * A document that is split into pages of mostly text content: books, articles, papers, etc. */ PAGED( PageOrientation.PORTRAIT, defaultPageFormat = with(PageSizeFormat.A4.getBounds(PageOrientation.PORTRAIT)) { PageFormatInfo( pageWidth = width, pageHeight = height, ) }, defaultNumbering = DocumentNumbering( headings = NumberingFormat.fromString("1.1.1"), figures = NumberingFormat.fromString("1.1"), tables = NumberingFormat.fromString("1.1"), math = NumberingFormat.fromString("(1)"), ), ), /** * A slides-based document for presentations. */ SLIDES(PageOrientation.LANDSCAPE), /** * A document optimized for documentation, knowledge bases, and wikis. */ DOCS(PageOrientation.PORTRAIT), } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/DocumentLayoutInfo.kt ================================================ package com.quarkdown.core.document.layout import com.quarkdown.core.document.layout.caption.CaptionPositionInfo import com.quarkdown.core.document.layout.font.FontInfo import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.page.mergeAll import com.quarkdown.core.document.layout.paragraph.ParagraphStyleInfo /** * Mutable information about the layout options of a document. * When any of the fields is `null`, the default value supplied by the underlying renderer is used. * @param pageFormats format of the pages of the document, in ascending order of precedence * @param fonts list of font configurations used in the document, in order of precedence * @param paragraphStyle style of paragraphs in the document * @param captionPosition position of captions of figures, tables, and more in the document */ data class DocumentLayoutInfo( val pageFormats: List = emptyList(), val fonts: List = emptyList(), val paragraphStyle: ParagraphStyleInfo = ParagraphStyleInfo(), val captionPosition: CaptionPositionInfo = CaptionPositionInfo(), ) { /** * Returns the effective page formats by prepending [default] (if non-null) * and folding formats that share the same [PageFormatInfo.selector]. * * Within each selector group, later formats take priority: their non-null fields * override earlier ones via `merge`, while null fields inherit from the layer beneath. */ fun getPageFormatsWithDefault(default: PageFormatInfo?): List { val all = default?.let { listOf(it) + pageFormats } ?: pageFormats return all .groupBy { it.selector } .values .map { formats -> formats.mergeAll() } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/caption/CaptionPosition.kt ================================================ package com.quarkdown.core.document.layout.caption import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * Possible positions of captions, relative to the element they describe. * @see CaptionPositionInfo */ enum class CaptionPosition : RenderRepresentable { TOP, BOTTOM, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/caption/CaptionPositionInfo.kt ================================================ package com.quarkdown.core.document.layout.caption import com.quarkdown.amber.annotations.Mergeable /** * Immutable information about the position of captions of [com.quarkdown.core.ast.quarkdown.CaptionableNode] nodes in a document. * @param default default relative position of captions * @param figures position of captions for [com.quarkdown.core.ast.quarkdown.block.Figure], if different from the default * @param tables position of captions for [com.quarkdown.core.ast.base.block.Table], if different from the default * @param codeBlocks position of captions for [com.quarkdown.core.ast.base.block.Code], if different from the default */ @Mergeable data class CaptionPositionInfo( val default: CaptionPosition = CaptionPosition.BOTTOM, val figures: CaptionPosition? = null, val tables: CaptionPosition? = null, val codeBlocks: CaptionPosition? = null, ) { /** * @return the value of [property] if it is not `null`, otherwise the [default] value. */ fun getOrDefault(property: CaptionPositionInfo.() -> CaptionPosition?): CaptionPosition = property() ?: default } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/font/FontInfo.kt ================================================ package com.quarkdown.core.document.layout.font import com.quarkdown.amber.annotations.Mergeable import com.quarkdown.core.document.size.Size import com.quarkdown.core.misc.font.FontFamily /** * Immutable information about the global font configuration of a document. * When any of the fields is `null`, the default value supplied by the underlying renderer is used. * @param mainFamily font family of generic content * @param headingFamily font family of headings * @param codeFamily font family of code blocks and code spans * @param size font size of generic content. Other elements will scale accordingly */ @Mergeable data class FontInfo( val mainFamily: FontFamily? = null, val headingFamily: FontFamily? = null, val codeFamily: FontFamily? = null, val size: Size? = null, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/page/PageFormatInfo.kt ================================================ package com.quarkdown.core.document.layout.page import com.quarkdown.amber.annotations.Mergeable import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.misc.color.Color /** * Immutable information about the format of all pages of a document. * When any of the fields is `null`, the default value supplied by the underlying renderer is used. * * A document may have multiple [PageFormatInfo] instances with different [selector] values, * allowing distinct formatting for specific page sides or page ranges * (e.g. mirrored margins in book-style layouts, or different margins for the first few pages). * * @param selector the scope this format applies to (side, range, or both), or `null` for global (all pages) * @param pageWidth width of each page * @param pageHeight height of each page * @param margin blank space around the content of each page * @param contentBorderWidth width of the border around the content area of each page * @param contentBorderColor color of the border around the content area of each page * @param columnCount number of columns on each page. If set, the layout becomes multi-column * @param alignment text alignment of the content of each page */ @Mergeable data class PageFormatInfo( val selector: PageFormatSelector? = null, val pageWidth: Size? = null, val pageHeight: Size? = null, val margin: Sizes? = null, val contentBorderWidth: Sizes? = null, val contentBorderColor: Color? = null, val columnCount: Int? = null, val alignment: Container.TextAlignment? = null, ) /** * Determines which pages a [PageFormatInfo] applies to. * A selector can target a specific [side] (left/right pages), a [range] of 1-based page indices, * or both (e.g. left pages within a given range). * * A `null` selector on [PageFormatInfo] means global scope (all pages). * * @param side the page side to target, or `null` for both sides * @param range 1-based inclusive range of page indices to target, or `null` for all pages */ data class PageFormatSelector( val side: PageSide? = null, val range: Range? = null, ) { /** * Whether this selector matches all pages (i.e. both [side] and [range] are `null`). */ val isGlobal: Boolean get() = side == null && range == null } /** * Merges a list of [PageFormatInfo] layers into a single instance. * Later entries take priority: their non-null fields override earlier ones. */ fun List.mergeAll(): PageFormatInfo = reduce { acc, format -> format.merge(acc) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/page/PageMarginPosition.kt ================================================ package com.quarkdown.core.document.layout.page import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * Position of a margin box on a page. * @param whenOnRightPage if not `null`, the position to use when the margin content is on a right page. * Typically used for mirror positions (outside/inside). * @param whenOnLeftPage if not `null`, the position to use when the margin content is on a left page. * Typically used for mirror positions (outside/inside). * @see com.quarkdown.core.document.layout.page.PageMarginPosition */ enum class PageMarginPosition( private val whenOnLeftPage: PageMarginPosition? = null, private val whenOnRightPage: PageMarginPosition? = null, ) : RenderRepresentable { // See: https://pagedjs.org/documentation/7-generated-content-in-margin-boxes/ // Ordered by position, clockwise from the top left corner. TOP_LEFT_CORNER, TOP_LEFT, TOP_CENTER, TOP_RIGHT, TOP_RIGHT_CORNER, RIGHT_TOP, RIGHT_MIDDLE, RIGHT_BOTTOM, BOTTOM_RIGHT_CORNER, BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT, BOTTOM_LEFT_CORNER, LEFT_BOTTOM, LEFT_MIDDLE, LEFT_TOP, // Mirror outside positions. TOP_OUTSIDE_CORNER(whenOnLeftPage = TOP_LEFT_CORNER, whenOnRightPage = TOP_RIGHT_CORNER), TOP_OUTSIDE(whenOnLeftPage = TOP_LEFT, whenOnRightPage = TOP_RIGHT), BOTTOM_OUTSIDE_CORNER(whenOnLeftPage = BOTTOM_LEFT_CORNER, whenOnRightPage = BOTTOM_RIGHT_CORNER), BOTTOM_OUTSIDE(whenOnLeftPage = BOTTOM_LEFT, whenOnRightPage = BOTTOM_RIGHT), // Mirror inside positions. TOP_INSIDE_CORNER(whenOnLeftPage = TOP_RIGHT_CORNER, whenOnRightPage = TOP_LEFT_CORNER), TOP_INSIDE(whenOnLeftPage = TOP_RIGHT, whenOnRightPage = TOP_LEFT), BOTTOM_INSIDE_CORNER(whenOnLeftPage = BOTTOM_RIGHT_CORNER, whenOnRightPage = BOTTOM_LEFT_CORNER), BOTTOM_INSIDE(whenOnLeftPage = BOTTOM_RIGHT, whenOnRightPage = BOTTOM_LEFT), ; /** * The position to use when rendering the margin content on a left page. * @return [whenOnLeftPage] if specified, otherwise the fixed position itself. */ val forLeftPage: PageMarginPosition get() = this.whenOnLeftPage ?: this /** * The position to use when rendering the margin content on a right page. * @return [whenOnRightPage] if specified, otherwise the fixed position itself. */ val forRightPage: PageMarginPosition get() = this.whenOnRightPage ?: this override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/page/PageOrientation.kt ================================================ package com.quarkdown.core.document.layout.page /** * The orientation of a page. */ enum class PageOrientation { /** * Vertical orientation, where `height >= width` */ PORTRAIT, /** * Horizontal orientation, where `width > height` */ LANDSCAPE, } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/page/PageSide.kt ================================================ package com.quarkdown.core.document.layout.page import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * The side of a page in a paged document, used to apply distinct formatting * (e.g. mirrored margins) to left and right pages via [PageFormatInfo]. */ enum class PageSide : RenderRepresentable { /** Left-hand (verso) pages in a spread. */ LEFT, /** Right-hand (recto) pages in a spread. */ RIGHT, ; override fun accept(visitor: RenderRepresentableVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/page/PageSizeFormat.kt ================================================ package com.quarkdown.core.document.layout.page import com.quarkdown.core.document.size.BoundingBox import com.quarkdown.core.document.size.by import com.quarkdown.core.document.size.inch import com.quarkdown.core.document.size.mm /** * Standard page sizes. * @param bounds size the page */ enum class PageSizeFormat( private val bounds: BoundingBox, ) { A0(841.0.mm by 1189.0.mm), A1(594.0.mm by 841.0.mm), A2(420.0.mm by 594.0.mm), A3(297.0.mm by 420.0.mm), A4(210.0.mm by 297.0.mm), A5(148.0.mm by 210.0.mm), A6(105.0.mm by 148.0.mm), A7(74.0.mm by 105.0.mm), A8(52.0.mm by 74.0.mm), A9(37.0.mm by 52.0.mm), A10(26.0.mm by 37.0.mm), B0(1000.0.mm by 1414.0.mm), B1(707.0.mm by 1000.0.mm), B2(500.0.mm by 707.0.mm), B3(353.0.mm by 500.0.mm), B4(250.0.mm by 353.0.mm), B5(176.0.mm by 250.0.mm), LETTER(8.5.inch by 11.0.inch), LEGAL(8.5.inch by 14.0.inch), LEDGER(11.0.inch by 17.0.inch), ; /** * Base orientation of the format. */ private val orientation: PageOrientation // Assuming width and height are declared with the same size unit. get() = if (bounds.width.value > bounds.height.value) PageOrientation.LANDSCAPE else PageOrientation.PORTRAIT /** * @param orientation orientation of the page * @return the bounds of the format for the given orientation * If, for instance, the document is landscape and the given format is portrait, the format is converted to landscape. */ fun getBounds(orientation: PageOrientation): BoundingBox = if (this.orientation == orientation) bounds else bounds.rotated } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/layout/paragraph/ParagraphStyleInfo.kt ================================================ package com.quarkdown.core.document.layout.paragraph import com.quarkdown.amber.annotations.Mergeable /** * Immutable information about the style of paragraphs in a document. * When any of the fields is `null`, the default value supplied by the underlying renderer is used. * @param lineHeight height of each line, multiplied by the font size * @param letterSpacing whitespace between letters, multiplied by the font size * @param spacing whitespace between paragraphs, multiplied by the font size * @param indent whitespace at the start of each paragraph, following LaTeX's policy, multiplied by the font size */ @Mergeable data class ParagraphStyleInfo( val lineHeight: Double? = null, val letterSpacing: Double? = null, val spacing: Double? = null, val indent: Double? = null, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/numbering/DocumentNumbering.kt ================================================ package com.quarkdown.core.document.numbering import com.quarkdown.amber.annotations.Mergeable import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.quarkdown.block.Math /** * An immutable group of [NumberingFormat]s for different types of elements ([Node]s) in a document. * @param headings format for [Heading]s * @param figures format for [Figure]s * @param tables format for [Table]s * @param math format for [Math] blocks * @param codeBlocks format for [Code] blocks * @param footnotes format for [FootnoteDefinition] and [ReferenceFootnote]s * @param extra extra, dynamic formats for custom elements (e.g. [com.quarkdown.core.ast.quarkdown.block.Numbered]) */ @Mergeable data class DocumentNumbering( val headings: NumberingFormat? = null, val figures: NumberingFormat? = null, val tables: NumberingFormat? = null, val math: NumberingFormat? = null, val codeBlocks: NumberingFormat? = null, val footnotes: NumberingFormat? = null, val extra: Map = emptyMap(), ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/numbering/NumberingCounterSymbol.kt ================================================ package com.quarkdown.core.document.numbering import com.github.fracpete.romannumerals4j.RomanNumeralFormat import com.quarkdown.core.util.StringCase import com.quarkdown.core.util.case /** * Represents a [NumberingSymbol] within a [NumberingFormat] with the responsibility of * counting items (e.g. headings) according to a specific rule (strategy). */ interface NumberingCounterSymbol : NumberingSymbol { /** * The range of values that this symbol can map to. * If a value is outside this range, it cannot be mapped * and an alternative strategy should be used. * In the default [NumberingFormat.format] implementation, * out-of-range values are simply mapped to their decimal representation. * E.g. [AlphaNumberingSymbol] can map values from 1-26 as `A`-`Z`. Value `0` is formatted as `0`. */ val supportedRange: IntRange get() = 0..Int.MAX_VALUE /** * Maps a numeric value to a string according to the numbering strategy. * @param index numeric value to map * @return string representation of the index according to the numbering strategy */ fun map(index: Int): String } /** * A numbering strategy that counts items as integers: `0, 1, 2, 3, ...` */ data object DecimalNumberingSymbol : NumberingCounterSymbol { override fun map(index: Int) = index.toString() } /** * A numbering strategy that counts items as letters of the latin alphabet: `0, A, B, C, ...` * @param case whether the letters should be uppercase or lowercase */ data class AlphaNumberingSymbol( val case: StringCase, ) : NumberingCounterSymbol { override val supportedRange: IntRange get() = 1..'Z' - 'A' + 1 override fun map(index: Int) = ('A' + index - 1).toString().case(case) } /** * A numbering strategy that counts items as Roman numerals: `0, I, II, III, ...` * @param case whether the letters should be uppercase or lowercase */ data class RomanNumberingSymbol( val case: StringCase, ) : NumberingCounterSymbol { // Provided by the romannumerals4j library: https://github.com/fracpete/romannumerals4j private val format = RomanNumeralFormat() override val supportedRange: IntRange get() = 1..3999 override fun map(index: Int) = index.let { format.format(it)?.case(case) ?: throw IllegalStateException("Failed to format $it as a roman numeral") } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/numbering/NumberingFixedSymbol.kt ================================================ package com.quarkdown.core.document.numbering /** * A [NumberingSymbol] that represents a fixed character in a numbering format, * such as the dots `.`, in the format `1.A.a`. * @param value fixed character to be used */ data class NumberingFixedSymbol( val value: Char, ) : NumberingSymbol ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/numbering/NumberingFormat.kt ================================================ package com.quarkdown.core.document.numbering import com.quarkdown.core.ast.attributes.location.SectionLocation import com.quarkdown.core.document.numbering.NumberingFormat.Companion.fromString import com.quarkdown.core.util.StringCase /** * Represents a format that defines how items (e.g. headings) are numbered in a document, * depending on their relative position and level of nesting. * For example, a format `1.A.a` would result in the following numbering: * - 1 * - 1.A * - 1.B * - 1.B.a * - 2 * - 2.A * - 2.A.a * - 2.B * In this example, the format consists of the following symbols: * - `1` is a [DecimalNumberingSymbol], which counts `1, 2, 3, ...` * - `.` is a [NumberingFixedSymbol] * - `A` is an uppercase [AlphaNumberingSymbol], which counts `A, B, C, ...` * - `.` is a [NumberingFixedSymbol] * - `a` is a lowercase [AlphaNumberingSymbol], which counts `a, b, c, ...` * A format can be imported and exported as a string via [fromString] and [format] respectively. * @param symbols ordered list of symbols that define the format * @see NumberingSymbol */ data class NumberingFormat( val symbols: List, ) { constructor(vararg symbols: NumberingSymbol) : this(symbols.toList()) /** * The size of the subset of [symbols] which contribute towards the dynamic numbering. * @see NumberingCounterSymbol */ private val counterSymbolCount: Int by lazy { symbols.filterIsInstance().count() } /** * The accuracy of the numbering format: the number of nesting levels that the format can cover. * For example: * - `1` has an accuracy of 1. * - `1.1` has an accuracy of 2. * - `1.1.1` has an accuracy of 3. */ val accuracy: Int get() = counterSymbolCount /** * Whether the format does not contain any counting symbols. */ val isNonCounting: Boolean get() = counterSymbolCount <= 0 /** * Converts the numbering format into a string. * For example, the [NumberingFormat] `1.A.a` would format the levels `1, 1, 0` as `2.B.a`. * * In case [SectionLocation.levels] and [symbols] have different lengths, (i.e. [SectionLocation.depth] is different from [accuracy]), * the output will be truncated to the shortest of the two: * for example, `1.A.a` formats the levels `1, 1, 0, 0` as `2.B.a`, and the levels `1, 1` as `2.B`. * * In case depth is less than accuracy, trailing fixed symbols are ignored. * For example, the format `(1.1)` will format correctly the levels `1, 2` as `(1.2)`, * but `1` will be wrongly truncated to `(1`. * * @param location location to format. * For example, when it comes to numbering headings, the level `[1, 1, 0]` correspond to: * ```markdown * # A * ## A.A * # B * ## B.A * ## B.B * ### B.B.A <-- This is the target level! * # C * ``` * @param allowMismatchingLength if `false`, the result string is empty if the current format's length * is too short to cover the nesting level of [location]. If `true`, the result is truncated to the format's length. * @return the formatted string */ fun format( location: SectionLocation, allowMismatchingLength: Boolean = true, ): String { // For example, the format 1.1 cannot cover a 3-levels nested location. if (!allowMismatchingLength && counterSymbolCount < location.levels.size) { return "" } val levels = location.levels.iterator() val builder = StringBuilder() for (symbol in symbols) { // Case levels.length < symbols.length: ignore the remaining symbols, except if accuracy and depth match. if (!levels.hasNext() && accuracy != location.depth) break // Appending the corresponding symbol. when (symbol) { // The counter maps the nesting level to a string. is NumberingCounterSymbol -> { val level = levels.next() if (level in symbol.supportedRange) symbol.map(level) else level.toString() } // Fixed symbols are directly appended as-is. is NumberingFixedSymbol -> { symbol.value.toString() } }.let(builder::append) } return builder.toString() } /** * Formats a single level of numbering. Use case: footnotes, which don't expect a nested structure. * @param level level to format * @see format */ fun format(level: Int): String = format(SectionLocation(listOf(level))) companion object { /** * @return the [NumberingSymbol] corresponding to the given [char], according to the mapping */ private fun charToSymbol(char: Char): NumberingSymbol = when (char) { '1' -> DecimalNumberingSymbol 'A' -> AlphaNumberingSymbol(StringCase.Upper) 'a' -> AlphaNumberingSymbol(StringCase.Lower) 'I' -> RomanNumberingSymbol(StringCase.Upper) 'i' -> RomanNumberingSymbol(StringCase.Lower) else -> NumberingFixedSymbol(char) } /** * Parses a [NumberingFormat] from a string, * where each character represents a symbol. * * `1`, `a`, `A`, `i` and `I` are reserved for counting, * while any other character is considered a fixed symbol. * * A backslash (`\`) escapes the next character, treating it as a fixed symbol * regardless of whether it would normally be a counting symbol. * For example, `\1` produces a literal `1` fixed symbol. * * @param string string to parse * @return parsed numbering format */ fun fromString(string: String): NumberingFormat = buildList { var escapeNext = false string.forEach { char -> when { escapeNext -> { add(NumberingFixedSymbol(char)) escapeNext = false } char == '\\' -> { escapeNext = true } else -> { add(charToSymbol(char)) } } } }.let(::NumberingFormat) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/numbering/NumberingSymbol.kt ================================================ package com.quarkdown.core.document.numbering /** * Any symbol that appears in a numbering format. * For example, in the format `1.A.a`: * - `1` is a [DecimalNumberingSymbol], which counts `1, 2, 3, ...` * - `.` is a [NumberingFixedSymbol] * - `A` is an uppercase [AlphaNumberingSymbol], which counts `A, B, C, ...` * - `.` is a [NumberingFixedSymbol] * - `a` is a lowercase [AlphaNumberingSymbol], which counts `a, b, c, ...` * @see NumberingCounterSymbol * @see NumberingFixedSymbol * @see NumberingFormat */ sealed interface NumberingSymbol ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/size/BoundingBox.kt ================================================ package com.quarkdown.core.document.size /** * A generic bounding box with a width and a height. */ data class BoundingBox( val width: Size, val height: Size, ) { /** * A 90-degrees rotated version of this bounding box, * which happens to be a new [BoundingBox] with the height and width swapped. */ val rotated: BoundingBox get() = BoundingBox(height, width) } /** * Shorthand for creating a [BoundingBox] from two [Size]s. * @param height height of the bounding box * @return a new [BoundingBox] with [this] width and the given [height] */ infix fun Size.by(height: Size) = BoundingBox(this, height) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/size/Size.kt ================================================ package com.quarkdown.core.document.size import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * A numeric size with a unit, which represents a generic size (e.g. margin, length, font size). */ data class Size( val value: Double, val unit: Unit, ) : RenderRepresentable { override fun toString() = "$value${unit.symbol}" // e.g. 10px, 5cm, 2in override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) /** * Unit of a [Size]. */ enum class Unit( val symbol: String, ) { PIXELS("px"), POINTS("pt"), CENTIMETERS("cm"), MILLIMETERS("mm"), INCHES("in"), PERCENTAGE("%"), } companion object { /** * An empty size. */ val ZERO = Size(0.0, Unit.PIXELS) } } /** * Represents a size expressed in pixels. */ val Double.px: Size get() = Size(this, Size.Unit.PIXELS) /** * Represents a size expressed in pixels. */ val Int.px: Size get() = this.toDouble().px /** * Represents a size expressed in centimeters. */ val Double.cm: Size get() = Size(this, Size.Unit.CENTIMETERS) /** * Represents a size expressed in millimeters. */ val Double.mm: Size get() = Size(this, Size.Unit.MILLIMETERS) /** * Represents a size expressed in inches. */ val Double.inch: Size get() = Size(this, Size.Unit.INCHES) /** * Represents a size in percentage. */ val Int.percent: Size get() = Size(this.toDouble(), Size.Unit.PERCENTAGE) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/size/Sizes.kt ================================================ package com.quarkdown.core.document.size import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * A collection of generic top, right, bottom and left [Size]s. */ data class Sizes( val top: Size, val right: Size, val bottom: Size, val left: Size, ) : RenderRepresentable { /** * Creates a [Sizes] object with the same [Size] for all sides. */ constructor(all: Size) : this(all, all, all, all) /** * Creates a [Sizes] object with the same [Size] for vertical (top, bottom) and horizontal (left, right) sides. */ constructor(vertical: Size, horizontal: Size) : this(vertical, horizontal, vertical, horizontal) override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/slides/Transition.kt ================================================ package com.quarkdown.core.document.slides import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * An animated transition between two slides. * @param style transition type * @param speed speed of the transition */ data class Transition( val style: Style, val speed: Speed = Speed.DEFAULT, ) { /** * Transition types. */ enum class Style : RenderRepresentable { NONE, FADE, SLIDE, ZOOM, ; override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } /** * Transition speeds. */ enum class Speed : RenderRepresentable { DEFAULT, FAST, SLOW, ; override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/sub/Subdocument.kt ================================================ package com.quarkdown.core.document.sub import java.io.File private const val ROOT_NAME = "index" private const val UNIQUE_NAME_FORMAT = "%s@%d" /** * A subdocument in a Quarkdown document is an independent unit that can be rendered separately, * can be linked from other documents, and can link to other subdocuments in order to create a graph of subdocuments, * available via [com.quarkdown.core.context.Context.sharedSubdocumentsData]. * * Additionally, a [com.quarkdown.core.rendering.PostRenderer] may adopt different strategies for rendering * different kinds of subdocuments. * For instance, an HTML post-renderer may render the root subdocument as a full HTML structure, with HTML, CSS and JS, * while a non-root subdocument may be just a single HTML resource linked from another document. */ sealed interface Subdocument { /** * The name of the subdocument, without any file extension. */ val name: String /** * A unique name for the subdocument, which reduces the risk of name collisions. * This is a suitable name for output resources. */ val uniqueName: String get() = name /** * The root [Subdocument], which is the main document of the Quarkdown pipeline. * This is always the entry point of the compilation process, * as it is the input content that is supplied to the pipeline. * * It is not bound to a file or resource, since it may be provided as a string or from other sources * that do not have a file representation. * * The root subdocument is the starting point of the subdocument graph, * available via [com.quarkdown.core.context.Context.sharedSubdocumentsData]. */ data object Root : Subdocument { override val name: String get() = ROOT_NAME } /** * A [Subdocument] that is bound to a file or resource available at a specific path * and can be referenced by a link from the main document or another subdocument. * @param name the name of the subdocument, without extension * @param path the absolute path to the subdocument file or resource * @param workingDirectory the working directory to be used to resolve relative file paths within the subdocument. * Note that if this is `null`, then the pipeline's working directory should be used. * To get consistent results, rely on the context's [com.quarkdown.core.context.file.FileSystem.workingDirectory]. * @param content the subdocument text content */ class Resource( override val name: String, val path: String, val workingDirectory: File? = null, val content: CharSequence, ) : Subdocument { override val uniqueName: String get() = UNIQUE_NAME_FORMAT.format(name, path.hashCode()) override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Resource) return false return path == other.path } override fun hashCode(): Int = path.hashCode() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/sub/SubdocumentOutputNaming.kt ================================================ package com.quarkdown.core.document.sub import com.quarkdown.core.context.Context /** * Strategy for naming subdocument output files. * @see Subdocument.getOutputFileName */ enum class SubdocumentOutputNaming { /** * Uses the subdocument's file name. */ FILE_NAME, /** * Uses a hash-based unique name to minimize collisions. */ COLLISION_PROOF, /** * Uses the document name set via `.docname`, falling back to [FILE_NAME] if unset. */ DOCUMENT_NAME, } /** * Returns the output file name for the subdocument, based on the context's [SubdocumentOutputNaming] strategy. * @param context the context that holds the pipeline options * @return the output file name for the subdocument * @see com.quarkdown.core.pipeline.PipelineOptions.subdocumentNaming */ fun Subdocument.getOutputFileName(context: Context): String = when (context.attachedPipeline?.options?.subdocumentNaming) { SubdocumentOutputNaming.COLLISION_PROOF -> uniqueName SubdocumentOutputNaming.DOCUMENT_NAME -> context.documentInfo.name ?: name else -> name } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/document/tex/TexInfo.kt ================================================ package com.quarkdown.core.document.tex /** * Immutable TeX configuration that affects math typesetting. * @param macros custom user-defined macros to be used in math expressions */ data class TexInfo( val macros: Map = emptyMap(), ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/LexerFactory.kt ================================================ package com.quarkdown.core.flavor import com.quarkdown.core.lexer.Lexer /** * Provider of [Lexer] instances. Each factory method returns a specialized implementation for a specific kind of tokenization. */ interface LexerFactory { /** * @param source raw input * @return a new [Lexer] instance that tokenizes macro blocks */ fun newBlockLexer(source: CharSequence): Lexer /** * @param source raw input * @return a new [Lexer] instance that tokenizes list items */ fun newListLexer(source: CharSequence): Lexer /** * @param source raw input * @param variant the variant of inline lexer to create, affecting which tokens are recognized. * For example, link labels don't recognize link tokens, * so the [InlineLexerVariant.LINK_LABEL] variant can be used * @return a new [Lexer] instance that tokenizes inline content */ fun newInlineLexer( source: CharSequence, variant: InlineLexerVariant = InlineLexerVariant.NORMAL, ): Lexer /** * @param source raw input * @param allowBlockFunctionCalls whether block function calls are tokenized too * @return a new [Lexer] instance that distinguishes text (static values) from function calls */ fun newExpressionLexer( source: CharSequence, allowBlockFunctionCalls: Boolean, ): Lexer } /** * Variants of inline lexers. */ enum class InlineLexerVariant { NORMAL, LINK_LABEL, } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/MarkdownFlavor.kt ================================================ package com.quarkdown.core.flavor /** * A flavor of Markdown. Each flavor consists of its own rules and extensions. * @see com.quarkdown.core.flavor.base.BaseMarkdownFlavor * @see com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor */ interface MarkdownFlavor { /** * The supplier of new lexer instances to tokenize various scopes from a raw string input. */ val lexerFactory: LexerFactory /** * The supplier of new parser instances to obtain processed nodes from raw tokens. */ val parserFactory: ParserFactory /** * The supplier of new tree iterator instances to traverse the node tree. */ val treeIteratorFactory: TreeIteratorFactory /** * The supplier of new renderer instances to convert processed nodes to output content. */ val rendererFactory: RendererFactory } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/ParserFactory.kt ================================================ package com.quarkdown.core.flavor import com.quarkdown.core.ast.Node import com.quarkdown.core.context.MutableContext import com.quarkdown.core.visitor.token.BlockTokenVisitor import com.quarkdown.core.visitor.token.InlineTokenVisitor import com.quarkdown.core.visitor.token.TokenVisitor import com.quarkdown.core.visitor.token.TokenVisitorAdapter /** * Provider of parser instances. Each factory method returns a specialized implementation for a specific kind of parsing. */ interface ParserFactory { /** * @param context writeable context data that is modified during the parsing process, * which carries useful information for the next stages of the pipeline * @return a new [BlockTokenVisitor] instance that parses tokens into [Node]s. */ fun newBlockParser(context: MutableContext): BlockTokenVisitor /** * @param context writeable context data that is modified during the parsing process, * which carries useful information for the next stages of the pipeline * @return a new [BlockTokenVisitor] instance that parses tokens into [Node]s. */ fun newInlineParser(context: MutableContext): InlineTokenVisitor /** * @param context writeable context data that is modified during the parsing process, * which carries useful information for the next stages of the pipeline * @return a new [TokenVisitor] instance that includes operations by both [newBlockParser] and [newInlineParser] */ fun newParser(context: MutableContext): TokenVisitor = TokenVisitorAdapter(newBlockParser(context), newInlineParser(context)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/RendererFactory.kt ================================================ package com.quarkdown.core.flavor import com.quarkdown.core.flavor.base.BaseMarkdownRendererFactory import com.quarkdown.core.flavor.quarkdown.QuarkdownRendererFactory /** * Provider of rendering strategies. * This factory is populated by extensions provided by external modules, such as `quarkdown-html`. * * See [com.quarkdown.rendering.html.extension.html] for an example of a renderer extension. */ interface RendererFactory { /** * Accepts a visitor to this renderer factory. * @param visitor the visitor to accept */ fun accept(visitor: RendererFactoryVisitor): T } /** * Visitor for renderer factories of different [MarkdownFlavor]. * @param T the type of the result of the visit operation */ interface RendererFactoryVisitor { fun visit(factory: BaseMarkdownRendererFactory): T fun visit(factory: QuarkdownRendererFactory): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/TreeIteratorFactory.kt ================================================ package com.quarkdown.core.flavor import com.quarkdown.core.ast.iterator.AstIterator import com.quarkdown.core.context.MutableContext /** * Provider of tree iterators. * A tree iterator is responsible for traversing the AST and performing operations * such as registrations, table of contents generation, etc. */ interface TreeIteratorFactory { /** * @param context the context of the document * @return the default tree iterator */ fun default(context: MutableContext): AstIterator } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/base/BaseMarkdownFlavor.kt ================================================ package com.quarkdown.core.flavor.base import com.quarkdown.core.flavor.LexerFactory import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.ParserFactory import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.flavor.TreeIteratorFactory /** * The vanilla [CommonMark](https://spec.commonmark.org) Markdown with several [GFM](https://github.github.com/gfm) features and extensions. */ object BaseMarkdownFlavor : MarkdownFlavor { override val lexerFactory: LexerFactory = BaseMarkdownLexerFactory override val parserFactory: ParserFactory = BaseMarkdownParserFactory() override val rendererFactory: RendererFactory = BaseMarkdownRendererFactory override val treeIteratorFactory: TreeIteratorFactory = BaseMarkdownTreeIteratorFactory() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/base/BaseMarkdownLexerFactory.kt ================================================ package com.quarkdown.core.flavor.base import com.quarkdown.core.flavor.InlineLexerVariant import com.quarkdown.core.flavor.LexerFactory import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.patterns.BaseMarkdownBlockTokenRegexPatterns import com.quarkdown.core.lexer.patterns.BaseMarkdownInlineTokenRegexPatterns import com.quarkdown.core.lexer.regex.StandardRegexLexer import com.quarkdown.core.lexer.tokens.PlainTextToken /** * [BaseMarkdownFlavor] lexer factory. */ object BaseMarkdownLexerFactory : LexerFactory { private val blockPatterns = BaseMarkdownBlockTokenRegexPatterns() private val inlinePatterns = BaseMarkdownInlineTokenRegexPatterns() override fun newBlockLexer(source: CharSequence): StandardRegexLexer = with(blockPatterns) { StandardRegexLexer( source, listOf( comment, blockQuote, blockCode, footnoteDefinition, linkDefinition, fencesCode, heading, horizontalRule, setextHeading, table, unorderedList, orderedList, newline, paragraph, blockText, ), ) } override fun newListLexer(source: CharSequence): StandardRegexLexer = with(blockPatterns) { StandardRegexLexer( source, listOf(listItem, newline), ) } override fun newInlineLexer( source: CharSequence, variant: InlineLexerVariant, ): StandardRegexLexer = with(inlinePatterns) { val acceptLinks = variant != InlineLexerVariant.LINK_LABEL val linkPatterns = if (acceptLinks) { arrayOf( diamondAutolink, link, referenceFootnote, referenceLink, urlAutolink, ) } else { emptyArray() } StandardRegexLexer( source, listOf( *linkPatterns, lineBreak, codeSpan, escape, entity, comment, image, referenceImage, strongEmphasisAsterisk, strongEmphasisUnderscore, emphasisAsterisk, emphasisUnderscore, strongAsterisk, strongUnderscore, strikethrough, criticalContent, ), fillTokenType = ::PlainTextToken, ) } // Functions aren't supported by this flavor override fun newExpressionLexer( source: CharSequence, allowBlockFunctionCalls: Boolean, ): Lexer = StandardRegexLexer(source, patterns = emptyList()) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/base/BaseMarkdownParserFactory.kt ================================================ package com.quarkdown.core.flavor.base import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.ParserFactory import com.quarkdown.core.parser.BlockTokenParser import com.quarkdown.core.parser.InlineTokenParser /** * [BaseMarkdownFlavor] parser factory. */ class BaseMarkdownParserFactory : ParserFactory { override fun newBlockParser(context: MutableContext) = BlockTokenParser(context) override fun newInlineParser(context: MutableContext) = InlineTokenParser(context) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/base/BaseMarkdownRendererFactory.kt ================================================ package com.quarkdown.core.flavor.base import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.flavor.RendererFactoryVisitor /** * [BaseMarkdownFlavor] renderer factory. */ data object BaseMarkdownRendererFactory : RendererFactory { override fun accept(visitor: RendererFactoryVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/base/BaseMarkdownTreeIteratorFactory.kt ================================================ package com.quarkdown.core.flavor.base import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.LinkUrlResolverHook import com.quarkdown.core.context.hooks.SubdocumentRegistrationHook import com.quarkdown.core.context.hooks.presence.CodePresenceHook import com.quarkdown.core.context.hooks.presence.MathPresenceHook import com.quarkdown.core.context.hooks.presence.MermaidDiagramPresenceHook import com.quarkdown.core.context.hooks.reference.FootnoteResolverHook import com.quarkdown.core.context.hooks.reference.LinkDefinitionResolverHook import com.quarkdown.core.flavor.TreeIteratorFactory /** * [BaseMarkdownFlavor] tree iterator factory. */ class BaseMarkdownTreeIteratorFactory : TreeIteratorFactory { override fun default(context: MutableContext): ObservableAstIterator = ObservableAstIterator() // Resolves reference links to their link definitions. .attach(LinkDefinitionResolverHook(context)) // Registers subdocuments. .attach(SubdocumentRegistrationHook(context)) // Resolves local URLs/paths for links and images loaded from different base paths. .attach(LinkUrlResolverHook(context)) // Resolves footnotes. .attach(FootnoteResolverHook(context)) // Allows loading code libraries (e.g. highlight.js syntax highlighting) // if at least one code block is present. .attach(CodePresenceHook(context)) // Allows loading Mermaid libraries // if at least one diagram is present. .attach(MermaidDiagramPresenceHook(context)) // Allows loading math libraries (e.g. KaTeX) // if at least one math block is present. .attach(MathPresenceHook(context)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/quarkdown/QuarkdownFlavor.kt ================================================ package com.quarkdown.core.flavor.quarkdown import com.quarkdown.core.flavor.LexerFactory import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.ParserFactory import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.flavor.TreeIteratorFactory /** * [com.quarkdown.core.flavor.base.BaseMarkdownFlavor] extension with, in addition: * - Functions * - Math blocks * - Code span additional content * - Image labels * - Table of contents * * And more. */ object QuarkdownFlavor : MarkdownFlavor { override val lexerFactory: LexerFactory = QuarkdownLexerFactory override val parserFactory: ParserFactory = QuarkdownParserFactory() override val rendererFactory: RendererFactory = QuarkdownRendererFactory() override val treeIteratorFactory: TreeIteratorFactory = QuarkdownTreeIteratorFactory() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/quarkdown/QuarkdownLexerFactory.kt ================================================ package com.quarkdown.core.flavor.quarkdown import com.quarkdown.core.flavor.InlineLexerVariant import com.quarkdown.core.flavor.LexerFactory import com.quarkdown.core.flavor.base.BaseMarkdownLexerFactory import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.patterns.FunctionCallPatterns import com.quarkdown.core.lexer.patterns.QuarkdownBlockTokenRegexPatterns import com.quarkdown.core.lexer.patterns.QuarkdownInlineTokenRegexPatterns import com.quarkdown.core.lexer.regex.StandardRegexLexer import com.quarkdown.core.lexer.tokens.PlainTextToken /** * [QuarkdownFlavor] lexer factory. */ object QuarkdownLexerFactory : LexerFactory { private val blockPatterns = QuarkdownBlockTokenRegexPatterns() private val inlinePatterns = QuarkdownInlineTokenRegexPatterns() private val functionCallPatterns = FunctionCallPatterns() private val base = BaseMarkdownLexerFactory /** * Inserts patterns of Quarkdown's inline extensions into the base inline lexer (produced by [BaseMarkdownLexerFactory]). * @return a copy of the base inline lexer also containing Quarkdown's inline extensions. */ private fun StandardRegexLexer.insertInlineExtensions(): Lexer { // New inline patterns introduced by this flavor on top of the base patterns. val inlineExtensions = with(inlinePatterns) { listOf( inlineFunctionCall, inlineMath, *textReplacements.toTypedArray(), ) } // The last pattern is the critical content one, which should always be last. return this.updatePatterns { patterns -> patterns.dropLast(1) + inlineExtensions + patterns.last() } } override fun newBlockLexer(source: CharSequence): Lexer = with(blockPatterns) { StandardRegexLexer( source, listOf( comment, functionCall, blockQuote, blockCode, footnoteDefinition, linkDefinition, fencesCode, multilineMath, onelineMath, heading, horizontalRule, pageBreak, setextHeading, table, unorderedList, orderedList, newline, paragraph, blockText, ), ) } override fun newListLexer(source: CharSequence): Lexer = base.newListLexer(source) override fun newInlineLexer( source: CharSequence, variant: InlineLexerVariant, ): Lexer = base.newInlineLexer(source, variant).insertInlineExtensions() override fun newExpressionLexer( source: CharSequence, allowBlockFunctionCalls: Boolean, ): Lexer = with(inlinePatterns) { // A function call argument contains textual content (string/number/...) // and possibly other nested function calls. StandardRegexLexer( source, if (allowBlockFunctionCalls) { listOf( escape, functionCallPatterns.expressionBlockFunctionCall, inlineFunctionCall, ) } else { listOf( escape, inlineFunctionCall, ) }, fillTokenType = ::PlainTextToken, ) } /** * Creates a lexer for inline function calls. * This lexer is mainly used for function call completion and highlighting in the LSP. * @param source the source text to tokenize * @return a lexer that recognizes inline function calls * (block arguments are not included, as they are part of block function calls) */ fun newInlineFunctionCallLexer(source: CharSequence): Lexer = StandardRegexLexer( source, listOf(inlinePatterns.inlineFunctionCall), ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/quarkdown/QuarkdownParserFactory.kt ================================================ package com.quarkdown.core.flavor.quarkdown import com.quarkdown.core.flavor.base.BaseMarkdownParserFactory /** * [QuarkdownFlavor] parser factory. * Quarkdown parsing doesn't differ from the base one, as new nodes are generated by the lexer, * instead of the parser. */ typealias QuarkdownParserFactory = BaseMarkdownParserFactory ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/quarkdown/QuarkdownRendererFactory.kt ================================================ package com.quarkdown.core.flavor.quarkdown import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.flavor.RendererFactoryVisitor /** * [QuarkdownRendererFactory] renderer factory. */ class QuarkdownRendererFactory : RendererFactory { override fun accept(visitor: RendererFactoryVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/flavor/quarkdown/QuarkdownTreeIteratorFactory.kt ================================================ package com.quarkdown.core.flavor.quarkdown import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.MediaStorerHook import com.quarkdown.core.context.hooks.TableOfContentsGeneratorHook import com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook import com.quarkdown.core.context.hooks.location.LocationAwarenessHook import com.quarkdown.core.context.hooks.location.NumberedEvaluatorHook import com.quarkdown.core.context.hooks.reference.BibliographyCitationResolverHook import com.quarkdown.core.context.hooks.reference.CrossReferenceResolverHook import com.quarkdown.core.flavor.TreeIteratorFactory import com.quarkdown.core.flavor.base.BaseMarkdownTreeIteratorFactory /** * [QuarkdownFlavor] tree iterator factory. */ class QuarkdownTreeIteratorFactory : TreeIteratorFactory { override fun default(context: MutableContext): ObservableAstIterator = BaseMarkdownTreeIteratorFactory() .default(context) .attach(LocationAwarenessHook(context)) .attach(LocationAwareLabelStorerHook(context)) .attach(NumberedEvaluatorHook(context)) .attach(TableOfContentsGeneratorHook(context)) .attach(CrossReferenceResolverHook(context)) .attach(BibliographyCitationResolverHook(context)) .apply { if (context.attachedPipeline?.options?.enableMediaStorage == true) { attach(MediaStorerHook(context)) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/Function.kt ================================================ package com.quarkdown.core.function import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.binding.ArgumentBindings import com.quarkdown.core.function.call.validate.FunctionCallValidator import com.quarkdown.core.function.value.OutputValue /** * A function that can be called from a Quarkdown source via a [FunctionCall]. * @param T expected output type */ interface Function> { /** * Function name. */ val name: String /** * Declared parameters. */ val parameters: List> /** * Validators that check the validity of a function call towards this function. * If a condition is not met during the validation, an exception should be thrown. */ val validators: List> /** * Function that maps the input arguments into an output value. * Arguments and [parameters] compliance in terms of matching types and count is not checked here. * The [ArgumentBindings] allow looking up argument values by their parameter. * * - [ArgumentBindings]: bindings between parameters and arguments for the function call * - [FunctionCall]: the function call that triggered this invocation */ val invoke: (ArgumentBindings, FunctionCall) -> T } /** * A basic [Function] implementation. * @see Function */ data class SimpleFunction>( override val name: String, override val parameters: List>, override val validators: List> = emptyList(), override val invoke: (ArgumentBindings, FunctionCall) -> T, ) : Function fun Function<*>.signatureAsString(includeName: Boolean = true) = buildString { if (includeName) { append(name) } append("(") append( parameters.joinToString { parameter -> buildString { if (parameter.isOptional) append("optional ") parameter.type.simpleName?.let { append(it).append(" ") } append(parameter.name) } }, ) append(")") } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/FunctionParameter.kt ================================================ package com.quarkdown.core.function import kotlin.reflect.KClass /** * A declared [Function] parameter. * @param name name of the parameter * @param type expected input value type * @param index index of the parameter in the function signature * @param isOptional whether the corresponding argument in a function call can be omitted * @param isInjected whether the corresponding argument in a function call is automatically injected * and is not to be supplied by the caller. * @param isNullable whether the parameter accepts `null` values. * When `true`, [com.quarkdown.core.function.value.NoneValue] arguments are accepted * and converted to Kotlin's `null` at invocation time. * @param T input type of the parameter */ data class FunctionParameter( val name: String, val type: KClass, val index: Int, val isOptional: Boolean = false, // When a function parameter is loaded from a KFunction via KFunctionAdapter, // a parameter is injected if it's annotated with `@Injected` val isInjected: Boolean = false, // When a function parameter is loaded from a KFunction via KFunctionAdapter, // a parameter is nullable if the corresponding KParameter type is marked nullable. val isNullable: Boolean = false, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/Naming.kt ================================================ package com.quarkdown.core.function /** * @return [this] string reformatted in Quarkdown format (lowercase, no underscores). i.e. `SPACE_AROUND` -> `spacearound` */ fun String.toQuarkdownNamingFormat(): String = lowercase().replace("_", "") /** * @return [this] enum's name in Quarkdown format (lowercase, no underscores). i.e. `SPACE_AROUND` -> `spacearound` */ val Enum<*>.quarkdownName: String get() = name.toQuarkdownNamingFormat() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/CallDepth.kt ================================================ package com.quarkdown.core.function.call import com.quarkdown.core.RUNTIME_ERROR_EXIT_CODE import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.util.ScopedCounter /** * Maximum allowed nesting depth for recursive function call execution. * Prevents stack overflows from infinite recursion in user-defined functions. */ private const val MAX_CALL_DEPTH = 512 /** * Tracks the current nesting depth of function call executions. */ internal val callDepth = ScopedCounter(MAX_CALL_DEPTH) { throw PipelineException( "Maximum function call depth ($MAX_CALL_DEPTH) exceeded. " + "This is likely caused by infinite recursion in a function call.", RUNTIME_ERROR_EXIT_CODE, ) } /** * A [PipelineException] that wraps a [StackOverflowError] occurring during function call expansion. * This may happen before [callDepth] is reached, e.g. during lexer pattern compilation on platforms * with a smaller default thread stack size (such as Windows). */ internal val stackOverflowPipelineException get() = PipelineException( "Stack overflow during function call expansion. " + "This is likely caused by infinite recursion in a function call.", RUNTIME_ERROR_EXIT_CODE, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/FunctionCall.kt ================================================ package com.quarkdown.core.function.call import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.Context import com.quarkdown.core.function.Function import com.quarkdown.core.function.call.binding.AllArgumentsBinder import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.pipeline.error.PipelineException /** * A call to a declared [Function]. * This is an [Expression] as its output can be used as an input for another function call. * @param function referenced function to call * @param arguments arguments of the call * @param context optional context this call lies in. * This value can be injected to library functions that demand it via the `@Injected` annotation * @param sourceNode [FunctionCallNode] that generated this call, if there is any. Like [context], this value can be injected. * It is `null` if the call is standalone and was not generated by a node. * @param onComplete function to be called when the function is executed, with the output of the call as an argument * @param T expected output type of the function */ data class FunctionCall>( val function: Function, val arguments: List, val context: Context? = null, val sourceNode: FunctionCallNode? = null, var onComplete: (T) -> Unit = { }, ) : Expression { private fun validate() { function.validators.forEach { it.validate(this) } } /** * Checks the call validity and calls the function. * @return the function output * @throws PipelineException if the maximum call depth is exceeded * @throws Exception if the validation through [Function.validators] does not succeed */ fun execute(): T { this.validate() // Allows binding each argument to its parameter. val bindings = AllArgumentsBinder(this).createBindings(function.parameters) return callDepth.incrementScoped { function.invoke(bindings, this).also(onComplete) } } override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/FunctionCallArgument.kt ================================================ package com.quarkdown.core.function.call import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.eval import com.quarkdown.core.function.value.Value /** * An argument of a [FunctionCall]. * @param expression expression that holds the content of the argument * @param name this argument's name, which should match that of a parameter of the called function * @param isBody whether this applies to a 'body' parameter, which must come last in the function signature */ data class FunctionCallArgument( val expression: Expression, val name: String? = null, val isBody: Boolean = false, ) { /** * The lazily evaluated output value of [expression]. */ val value: Value<*> by lazy { expression.eval() } /** * Whether this is a named argument. */ val isNamed: Boolean get() = name != null } /** * @return a string representation of [this] argument's value */ fun FunctionCallArgument.asString() = value.unwrappedValue.toString() /** * @return a string representation of [this] sequence of arguments */ fun List.asString() = "(" + joinToString { it.asString() } + ")" ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/FunctionCallNodeExpander.kt ================================================ package com.quarkdown.core.function.call import com.quarkdown.core.RUNTIME_ERROR_EXIT_CODE import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.MutableContext import com.quarkdown.core.function.value.output.OutputValueVisitorFactory import com.quarkdown.core.function.value.output.node.NodeOutputValueVisitorFactory import com.quarkdown.core.pipeline.error.PipelineErrorHandler import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.error.asNode /** * Given a [FunctionCallNode] from the AST, this expander resolves its referenced function, executes it * and maps its result to a visible output in the final document. * @param context root context to dequeue to-be-expanded function calls from * @param errorHandler strategy to handle errors that may occur during the execution of a function call * @param outputMapperFactory producer of an AST output [Node] from the function call output */ class FunctionCallNodeExpander( private val context: MutableContext, private val errorHandler: PipelineErrorHandler, private val outputMapperFactory: OutputValueVisitorFactory = NodeOutputValueVisitorFactory(context), ) { // Output-to-node mappers, for block and inline function calls respectively. private val blockMapper = outputMapperFactory.block() private val inlineMapper = outputMapperFactory.inline() /** * Resolves, executes and stores the result of [node]'s referenced function. * @param node AST function call node to expand */ private fun expand(node: FunctionCallNode) { if (node.children.isNotEmpty()) { // The function call has already been expanded: do nothing. return } try { // The function call node is used to retrieve its corresponding function call. // By resolving it from the node's context instead of the root one, // we make sure to call it from the correct scope, hence providing the needed environment. val call: UncheckedFunctionCall<*> = node.context.resolveUnchecked(node) // The result of the function is converted into a node to be appended to the AST. // The value-to-node mapper used depends on whether the function call is block or inline. val mapper = if (node.isBlock) blockMapper else inlineMapper val outputNode = call.execute().accept(mapper) appendOutput(node, outputNode) } catch (e: Exception) { // PipelineExceptions are rendered as-is. Other unexpected exceptions are wrapped for graceful error rendering. val pipelineException = e as? PipelineException ?: PipelineException(e.message ?: e.toString(), RUNTIME_ERROR_EXIT_CODE) appendOutput(node, pipelineException.asNode(errorHandler)) } catch (_: StackOverflowError) { appendOutput(node, stackOverflowPipelineException.asNode(errorHandler)) } } /** * Adds [output] to [call]'s content in the AST. * @param call function call node * @param output output node to append */ private fun appendOutput( call: FunctionCallNode, output: Node, ) { call.children += output } /** * Expands all unexpanded function calls present in [context], and empties queued function calls in [context]. * This is performed on a copy of the context's execution queue to avoid `ConcurrentModificationException`. * Hence, if a function call is added during the expansion, [expandAll] must be called again. */ fun expandAll() { val calls = context.dequeueAllFunctionCalls() calls.forEach { expand(it) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/UncheckedFunctionCall.kt ================================================ package com.quarkdown.core.function.call import com.quarkdown.core.function.error.UnresolvedReferenceException import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.OutputValue /** * Wrapper/delegate for a [FunctionCall] whose referenced function may or may not have been resolved. * If the result of [resolve] is `null`, the function wasn't resolved, and the delegate methods throw [UnresolvedReferenceException], * which is then caught by upper layers. * @param name name of the function call, shown in unresolved reference error messages * @param resolve supplier of the optional call, looked up by name * @param T expected output type of the function */ data class UncheckedFunctionCall>( val name: String, val resolve: () -> FunctionCall?, ) : Expression { /** * @return the result of `call.accept(visitor)` if [resolve] is not `null` * @throws UnresolvedReferenceException if [resolve] is `null` */ override fun accept(visitor: ExpressionVisitor): T = resolve()?.accept(visitor) ?: throw UnresolvedReferenceException(name) /** * @return the result of `call.execute()` if [resolve] is not `null` * @throws UnresolvedReferenceException if [resolve] is `null` */ fun execute(): T = resolve()?.execute() ?: throw UnresolvedReferenceException(name) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/binding/AllArgumentsBinder.kt ================================================ package com.quarkdown.core.function.call.binding import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.error.InvalidArgumentCountException /** * Builder of bindings for all arguments of a function call. * @param call function call to bind arguments for * @see RegularArgumentsBinder * @see InjectedArgumentsBinder */ class AllArgumentsBinder( private val call: FunctionCall<*>, ) : ArgumentsBinder { /** * Joins the results of the subsets of regular ([RegularArgumentsBinder]) * and injected ([InjectedArgumentsBinder]) arguments. */ override fun createBindings(parameters: List>): ArgumentBindings { val (injected, regular) = call.function.parameters.partition { it.isInjected } // Argument-parameter links are generated for both types of parameters and joined together. val bindings = RegularArgumentsBinder(call).createBindings(regular) + InjectedArgumentsBinder(call).createBindings(injected) // If mandatory params count > args count. if (call.function.parameters.any { !it.isOptional && it !in bindings }) { throw InvalidArgumentCountException(call) } return bindings } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/binding/ArgumentsBinder.kt ================================================ package com.quarkdown.core.function.call.binding import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.error.InvalidFunctionCallException /** * Parameter-argument pairs of a function call. */ typealias ArgumentBindings = Map, FunctionCallArgument> /** * Builder of parameter-argument pairs of a function call. * Allows binding each argument to its corresponding parameter, * and may throw an exception if some cannot be paired. * @see InjectedArgumentsBinder * @see InjectedArgumentsBinder * @see AllArgumentsBinder */ sealed interface ArgumentsBinder { /** * @param parameters parameters of the called function (or a subset of them) * @return the parameter-argument pairs * @throws InvalidFunctionCallException or subclass if there is arguments and parameters cannot be paired */ fun createBindings(parameters: List>): ArgumentBindings } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/binding/InjectedArgumentsBinder.kt ================================================ package com.quarkdown.core.function.call.binding import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.reflect.InjectedValue /** * Builder of bindings for the injected argument subset of a function call. * @param call function call to bind arguments for * @see FunctionParameter.isInjected */ class InjectedArgumentsBinder( private val call: FunctionCall<*>, ) : ArgumentsBinder { override fun createBindings(parameters: List>) = parameters.associateWith { val value = InjectedValue.fromType(it.type, call) FunctionCallArgument(value) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/binding/RegularArgumentsBinder.kt ================================================ package com.quarkdown.core.function.call.binding import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.error.InvalidArgumentCountException import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.core.function.error.MismatchingArgumentTypeException import com.quarkdown.core.function.error.ParameterAlreadyBoundException import com.quarkdown.core.function.error.UnnamedArgumentAfterNamedException import com.quarkdown.core.function.error.UnresolvedParameterException import com.quarkdown.core.function.reflect.DynamicValueConverter import com.quarkdown.core.function.value.AdaptableValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.isNone import com.quarkdown.core.pipeline.error.PipelineException import kotlin.reflect.full.isSubclassOf /** * Builder of bindings for the regular (not injected) argument subset of a function call. * @param call function call to bind arguments for * @see InjectedArgumentsBinder for the injected argument subset */ class RegularArgumentsBinder( private val call: FunctionCall<*>, ) : ArgumentsBinder { // As soon as a named argument is encountered, all following arguments must be named too. private var encounteredNamedArgument = false /** * Binds an argument to its corresponding parameter. * @param argument argument to bind * @param argumentIndex index of the argument in the call * @param parameters available parameters of the called function * @return the parameter bound to the given argument * @throws InvalidArgumentCountException if the number of arguments exceeds the number of parameters * @throws UnresolvedParameterException if the argument is named and refers to a non-existent parameter * @throws UnnamedArgumentAfterNamedException if an unnamed argument appears after a named one */ private fun findParameter( argument: FunctionCallArgument, argumentIndex: Int, parameters: List>, ): FunctionParameter<*> = when { // A body parameter is always the last one in the function signature. argument.isBody -> { parameters.lastOrNull() } // A non-body parameter that refers to a parameter by its name. argument.isNamed -> { encounteredNamedArgument = true parameters.find { it.name == argument.name } ?: throw UnresolvedParameterException(argument, call) } // Non-body, unnamed parameters follow the index and cannot appear after a named argument has been encountered. !encounteredNamedArgument -> { parameters.getOrNull(argumentIndex) } // Unnamed arguments cannot appear after a named one. else -> { throw UnnamedArgumentAfterNamedException(call) } } ?: throw InvalidArgumentCountException(call) // Error if args count > params count. /** * Converts an argument to the expected type of its corresponding parameter. * If it's a dynamic value, it is converted to a static type via a [ValueFactory]. * @param parameter parameter bound to the argument * @param argument argument to convert, which can be dynamic or static * @return a new argument, which holds [argument]'s value converted to the expected type * @throws MismatchingArgumentTypeException if the value cannot be converted to the expected type */ private fun getStaticallyTypedArgument( parameter: FunctionParameter<*>, argument: FunctionCallArgument, ): FunctionCallArgument { // The value held by the argument. // If the argument is dynamic, it is converted to a static type. val value = argument.value return when { // If the expected type is dynamic, the argument is wrapped into a dynamic value. // For instance, custom functions defined from a Quarkdown function have dynamic-type parameters. parameter.type == DynamicValue::class -> { argument.copy(expression = DynamicValue(value.unwrappedValue)) } // NoneValue is accepted for nullable parameters, representing Quarkdown's equivalent of null. parameter.isNullable && value.isNone() -> { argument.copy(expression = NoneValue) } // The value is dynamic and must be converted to a static type. value is DynamicValue -> { // The dynamic value is converted into the expected parameter type. // Throws error if the conversion could not happen. val staticValue = try { DynamicValueConverter(value).convertTo(parameter.type, call.context) } catch (e: PipelineException) { // In case the conversion fails, the error is wrapped so that it can refer to this function call as a source. throw InvalidFunctionCallException(call, e.message) } // convertTo returns null if the called ValueFactory method returns null. // This means the supplied value cannot be converted to the expected type. ?: throw MismatchingArgumentTypeException(call, parameter, argument) argument.copy(expression = staticValue) } // If the expected type is a string but the argument isn't, // it is automatically converted to a string. value !is StringValue && parameter.type == String::class -> { argument.copy(expression = ValueFactory.string(value.unwrappedValue.toString())) } // If the argument does not directly match the parameter type, but is adaptable, // it is adapted (or at least attempted) to the expected type. value is AdaptableValue<*> && !value::class.isSubclassOf(parameter.type) -> { val adapted = value.adapt() when { adapted::class.isSubclassOf(parameter.type) -> argument.copy(expression = adapted) adapted.unwrappedValue!!::class.isSubclassOf(parameter.type) -> argument.copy(expression = adapted) else -> argument } } else -> { argument } } } /** * Ensures the type of the argument matches the expected type of the parameter. * @param parameter parameter bound to the argument * @param argument statically typed argument to check * @throws MismatchingArgumentTypeException if the argument type does not match the parameter type */ private fun checkTypeMatch( parameter: FunctionParameter<*>, argument: FunctionCallArgument, ) { if (argument.value is NoneValue && parameter.isNullable) return if (argument.value.unwrappedValue!!::class.isSubclassOf(parameter.type)) return if (argument.value::class.isSubclassOf(parameter.type)) return throw MismatchingArgumentTypeException(call, parameter, argument) } override fun createBindings(parameters: List>): ArgumentBindings { // Parameters that have been already bound to arguments. val boundParameters = mutableSetOf>() return call.arguments .withIndex() .associate { (index, argument) -> // Corresponding parameter. val parameter = findParameter(argument, index, parameters) // Check if the parameter is already bound. when { parameter in boundParameters -> throw ParameterAlreadyBoundException(call, parameter, argument) else -> boundParameters += parameter } // The type of dynamic arguments is determined. val staticArgument = getStaticallyTypedArgument(parameter, argument) // Type match check. checkTypeMatch(parameter, staticArgument) // Push binding. parameter to staticArgument } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/validate/DocumentTypeFunctionCallValidator.kt ================================================ package com.quarkdown.core.function.call.validate import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.function.value.OutputValue /** * Validator of a function call that checks if the document the function call lies in is of a certain type. * If not, an [InvalidFunctionCallException] is thrown. * @param T output type of the function * @param allowedTypes allowed document types */ class DocumentTypeFunctionCallValidator>( private val allowedTypes: Iterable, ) : FunctionCallValidator { override fun validate(call: FunctionCall) { val type = call.context?.documentInfo?.type ?: return if (type in allowedTypes) { return } throw InvalidFunctionCallException( call, reason = "the function was called in a ${type.quarkdownName} document, " + "while it is allowed only in ${allowedTypes.joinToString { it.quarkdownName }}", includeArguments = false, ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/call/validate/FunctionCallValidator.kt ================================================ package com.quarkdown.core.function.call.validate import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.value.OutputValue /** * Validator of a function call. * @param T output type of the function */ interface FunctionCallValidator> { /** * Validates a function call. * If a condition is not met, an exception should be thrown (ideally, a [FunctionException] or subclass). * Validation should not have any side effects. */ fun validate(call: FunctionCall) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/FunctionCallRuntimeException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.function.call.FunctionCall /** * An exception thrown when an error occurs inside a called function, * which may wrap another exception as its cause, such as an [IllegalStateException]. * @param call the function call that caused the error * @param cause the error cause */ class FunctionCallRuntimeException( call: FunctionCall<*>, override val cause: Throwable, ) : InvalidFunctionCallException( call, reason = cause.message ?: "$cause (no further information)", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/FunctionException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.function.Function import com.quarkdown.core.pipeline.error.PipelineException /** * A [PipelineException] thrown when an error related to a function or function call occurs. * @param richMessage formatted message to display * @param function function the error is related to */ open class FunctionException( richMessage: InlineContent, code: Int, val function: Function<*>, ) : PipelineException(richMessage, code) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/InvalidArgumentCountException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.BAD_FUNCTION_CALL_EXIT_CODE import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.pipeline.error.PipelineException /** * An exception thrown if the amount of arguments and mandatory parameters of a function call does not match. * @param call the invalid call */ class InvalidArgumentCountException( call: FunctionCall<*>, ) : InvalidFunctionCallException( call, reason = "expected ${call.function.parameters.size} arguments, but ${call.arguments.size} found", ) /** * An exception thrown if the amount of arguments and mandatory parameters of a lambda block does not match. * @param argumentCount given argument count * @param parameterCount expected parameter count */ class InvalidLambdaArgumentCountException( argumentCount: Int, parameterCount: Int, ) : PipelineException( "Lambda expects $parameterCount arguments, but $argumentCount found", BAD_FUNCTION_CALL_EXIT_CODE, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/InvalidFunctionCallException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.BAD_FUNCTION_CALL_EXIT_CODE import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.asString import com.quarkdown.core.function.signatureAsString private const val TEXT_AUTOCOLLAPSE_MAX_LENGTH = 40 /** * An exception thrown if a [FunctionCall] could not be executed. * @param call the invalid call * @param reason optional additional reason the call failed for * @param includeArguments whether to include supplied function call arguments in the error message */ open class InvalidFunctionCallException( val call: FunctionCall<*>, reason: String? = null, includeArguments: Boolean = true, ) : FunctionException( richMessage = buildInline { text("Cannot call function ${call.function.name}") // If the signature is too long, it is collapsed by default and can be expanded by the user. autoCollapse( text = call.function.signatureAsString(includeName = false), maxLength = TEXT_AUTOCOLLAPSE_MAX_LENGTH, ) if (includeArguments) { text(" with arguments ") // The same applies to arguments. autoCollapse( text = call.arguments.asString(), maxLength = TEXT_AUTOCOLLAPSE_MAX_LENGTH, ) } reason?.let { text(": ") lineBreak() emphasis { text(it) } } }, code = BAD_FUNCTION_CALL_EXIT_CODE, function = call.function, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/MismatchingArgumentTypeException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument /** * An exception thrown if a parameter-argument pair of a function call has incompatible types. * @param call the invalid call */ class MismatchingArgumentTypeException( call: FunctionCall<*>, parameter: FunctionParameter<*>, argument: FunctionCallArgument, ) : InvalidFunctionCallException( call, reason = "expected type ${parameter.type.simpleName} for parameter '${parameter.name}', " + "but ${argument.value::class.simpleName} found", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/NoSuchElementException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.NO_SUCH_ELEMENT_EXIT_CODE import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.pipeline.error.PipelineException /** * Exception thrown when an element (e.g. an enum value from a Quarkdown function argument) * does not exist among elements of a look-up table. */ class NoSuchElementException( element: Any, values: Iterable<*>, ) : PipelineException("No such element '$element' among values $values", NO_SUCH_ELEMENT_EXIT_CODE) { constructor(element: Any, values: Array>) : this(element, values.map { it.quarkdownName }) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/ParameterAlreadyBoundException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.call.asString /** * An exception thrown if a function parameter is bound more than once in a function call. * @param call the invalid call * @param parameter the parameter that was attempted to be bound again * @param overriddingArgument the argument that was attempted to be bound to the already bound parameter */ class ParameterAlreadyBoundException( call: FunctionCall<*>, parameter: FunctionParameter<*>, overriddingArgument: FunctionCallArgument, ) : InvalidFunctionCallException( call, reason = "parameter '${parameter.name}' is already bound, but was attempted to be bound again to ${overriddingArgument.asString()}", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/UnnamedArgumentAfterNamedException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument /** * An exception thrown if an unnamed argument appears after at least one named argument has been encountered in a function call. * @param call the invalid call * @see FunctionCallArgument.isNamed */ class UnnamedArgumentAfterNamedException( call: FunctionCall<*>, ) : InvalidFunctionCallException( call, reason = "all arguments following a named argument must be named as well", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/UnresolvedParameterException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument /** * An exception thrown if a named argument does not match the name of any parameter from the called function. * @param argument the named argument * @param call the invalid call * @see FunctionCallArgument.isNamed */ class UnresolvedParameterException( argument: FunctionCallArgument, call: FunctionCall<*>, ) : InvalidFunctionCallException( call, reason = "cannot find parameter ${argument.name}, which was referenced by a named argument", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/UnresolvedReferenceException.kt ================================================ package com.quarkdown.core.function.error import com.quarkdown.core.UNRESOLVED_REFERENCE_EXIT_CODE import com.quarkdown.core.pipeline.error.PipelineException /** * An exception thrown when a function call does not reference any registered function declaration. * @param symbol function name */ class UnresolvedReferenceException( symbol: String, ) : PipelineException("Unresolved reference: $symbol", UNRESOLVED_REFERENCE_EXIT_CODE) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/error/internal/InvalidExpressionEvalException.kt ================================================ package com.quarkdown.core.function.error.internal import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.factory.ValueFactory /** * An exception thrown when an [Expression] cannot be evaluated. * Most commonly, this is thrown when a [NodeValue] appears in a [ComposedExpression], * hence the content must be parsed as Markdown instead of expression. * @see ValueFactory.eval */ class InvalidExpressionEvalException : Exception() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/ComposedExpression.kt ================================================ package com.quarkdown.core.function.expression import com.quarkdown.core.function.expression.visitor.ExpressionVisitor /** * An [Expression] composed by multiple sub-expressions. * * For example, in the Quarkdown source: * `.somefunction {three plus two is .sum {3} {2} and three minus two is .subtract {3} {2}}` * The argument to `somefunction` holds a composed expression built by these sub-expressions: * - `StringValue(three plus two is )` * - `FunctionCall(sum, 3, 2)` * - `StringValue( and three minus two is )` * - `FunctionCall(subtract, 3, 2)` * * @param expressions sub-expressions */ data class ComposedExpression( val expressions: List, ) : Expression { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/Expression.kt ================================================ package com.quarkdown.core.function.expression import com.quarkdown.core.function.expression.visitor.AppendExpressionVisitor import com.quarkdown.core.function.expression.visitor.EvalExpressionVisitor import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.Value /** * An expression that can be evaluated into a single static value. * Expressions are used in function arguments. */ interface Expression { /** * Accepts a visitor. * @param T output type of the visitor * @return output of the visit operation */ fun accept(visitor: ExpressionVisitor): T } /** * @return this expression, evaluated into a single static value * which can be chained as an input for another function * @see EvalExpressionVisitor */ fun Expression.eval(): Value<*> = this.accept(EvalExpressionVisitor()) /** * Chains two expressions together, which is used in [ComposedExpression]s. * @param other expression to append * @return an expression that contains this expression and [other], in order * @see AppendExpressionVisitor */ fun Expression.append(other: Expression): Expression = this.accept(AppendExpressionVisitor(other)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/SafeExpression.kt ================================================ package com.quarkdown.core.function.expression import com.quarkdown.core.function.error.internal.InvalidExpressionEvalException import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.factory.ValueFactory /** * An [Expression] that, upon failed evaluation due to an [InvalidExpressionEvalException], * delegates the operation to a safe fallback expression. * @see ValueFactory.safeExpression */ class SafeExpression( val expression: Expression, fallback: () -> Expression, ) : Expression { private val lazyFallback by lazy(fallback) override fun accept(visitor: ExpressionVisitor): T = try { expression.accept(visitor) } catch (e: InvalidExpressionEvalException) { lazyFallback.accept(visitor) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/visitor/AppendExpressionVisitor.kt ================================================ package com.quarkdown.core.function.expression.visitor import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.error.internal.InvalidExpressionEvalException import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.append import com.quarkdown.core.function.expression.eval import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.EnumValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.InlineMarkdownContentValue import com.quarkdown.core.function.value.InputValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.LambdaValue import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.VoidValue /** * An [ExpressionVisitor] that describes the way two expressions are joined together. * * For example, in the Quarkdown source: * `.somefunction {three plus two is .sum {3} {2}}` * The argument to `somefunction` is a [ComposedExpression] built by these sub-expressions: * - `StringValue(three plus two is )` * - `FunctionCall(sum, 3, 2)` * After the evaluation of the `sum` call (handled by [EvalExpressionVisitor]) has been executed, * the output values are: * - `StringValue(three plus two is )` * - `NumberValue(5)` * These two values are then joined together by this [AppendExpressionVisitor], producing: * `StringValue(three plus two is 5)` * * The same principle applies to 'block expressions': * ``` * .if {...} * Item 1 * .foreach {2..4} * n: * Item .n * Item 5 * ``` * The previous example contains a body composed of multiple expressions: * - `StringValue(Item 1)`; * - `FunctionCall(foreach)` which returns an `IterableValue` of 3 elements; * - `StringValue(Item 5)`. * After appending these values, the resulting expression is an `IterableValue` (a [GeneralCollectionValue] in particular) * which contains: `Item 1`, `Item 2`, `Item 3`, `Item 4`, `Item 5`. * * @param other expression to append to the visited expression * @see ComposedExpression */ class AppendExpressionVisitor( private val other: Expression, ) : ExpressionVisitor { private val otherEval by lazy { other.eval() } // Evaluate the next expression. /** * @return string result of the concatenation between [this] and [other] * @throws InvalidExpressionEvalException if either [this] or [other] is a [NodeValue] (see [com.quarkdown.core.function.value.factory.ValueFactory.eval]) */ private fun Value<*>.concatenate(): InputValue<*> { val otherEval = this@AppendExpressionVisitor.otherEval // Whenever a NodeValue appears in a composed expression, it means the expected output is strictly meant to be // a pure Markdown output node. Therefore, the thrown error is caught at eval-time and the expression // is re-processed as Markdown content. // See ValueFactory.eval for more information. if (this is NodeValue || otherEval is NodeValue) { throw InvalidExpressionEvalException() } // Void values are ignored. if (this is VoidValue) return otherEval as InputValue<*> if (otherEval is VoidValue) return this as InputValue<*> // If the other value is a collection, add the current value to it as the first element. if (otherEval is IterableValue<*> && this is OutputValue<*>) { return GeneralCollectionValue(listOf(this, *otherEval.unwrappedValue.toList().toTypedArray())) } if (this is GeneralCollectionValue<*>) { return GeneralCollectionValue(this.unwrappedValue + otherEval.unwrappedValue as OutputValue<*>) } // Concatenate the string representation of the two values. fun stringify(value: Value<*>) = when (value) { is VoidValue -> "" else -> value.unwrappedValue.toString() } return StringValue(stringify(this) + stringify(otherEval)) } // "abc" "def" -> "abcdef" // "abc" .sum {2} {3} -> "abc5" override fun visit(value: StringValue) = value.concatenate() // 15 "abc" -> "15abc" // 15 8 -> "158" override fun visit(value: NumberValue) = value.concatenate() // true false -> false // false true -> false // true true -> true // true "abc" -> "trueabc" override fun visit(value: BooleanValue): Expression = when (other) { // Logic AND between values. is BooleanValue -> BooleanValue(value.unwrappedValue && other.unwrappedValue) else -> value.concatenate() } // [a, b, c] "abc" -> [a, b, c, "abc"] override fun visit(value: OrderedCollectionValue<*>): Expression = OrderedCollectionValue( value.unwrappedValue + otherEval as OutputValue<*>, ) // [a, b, c] "abc" -> [a, b, c, "abc"] override fun visit(value: UnorderedCollectionValue<*>): Expression = UnorderedCollectionValue( value.unwrappedValue + otherEval as OutputValue<*>, ) // [a, b, c] "abc" -> [a, b, c, "abc"] override fun visit(value: GeneralCollectionValue<*>): GeneralCollectionValue<*> = GeneralCollectionValue( value.unwrappedValue + otherEval as OutputValue<*>, ) override fun visit(value: PairValue<*, *>): Expression = visit(GeneralCollectionValue(value.unwrappedValue)) // {a: 1, b: 2} "abc" -> "{a=1, b=2}abc" override fun visit(value: DictionaryValue<*>) = value.concatenate() // CENTER "abc" -> "CENTERabc" // CENTER CENTER -> "CENTERCENTER" // CENTER 15 -> "CENTER15" override fun visit(value: EnumValue) = value.concatenate() // obj "abc" -> "objabc" override fun visit(value: ObjectValue<*>) = value.concatenate() // MarkdownContent(Text("abc")) Text("def") -> MarkdownContent(Text("abc"), Text("abcdef")) // MarkdownContent(Text("abc")) "def" -> MarkdownContent(Text("abc"), Text("abcdef")) // MarkdownContent(Text("abc")) 15 -> MarkdownContent(Text("abc"), Text("15")) override fun visit(value: MarkdownContentValue): Expression = GeneralCollectionValue(listOf(value.asNodeValue(), otherEval as OutputValue<*>)) // InlineMarkdownContent(Text("abc")) Text("def") -> InlineMarkdownContent(Text("abc"), Text("abcdef")) // InlineMarkdownContent(Text("abc")) "def" -> InlineMarkdownContent(Text("abc"), Text("abcdef")) // InlineMarkdownContent(Text("abc")) 15 -> InlineMarkdownContent(Text("abc"), Text("15")) override fun visit(value: InlineMarkdownContentValue): Expression = GeneralCollectionValue(listOf(value.asNodeValue(), otherEval as OutputValue<*>)) override fun visit(value: NodeValue): Expression = throw InvalidExpressionEvalException() // DynamicValue(15) "abc" -> "15abc" // DynamicValue("abc") [1, 2, 3] -> ["abc", 1, 2, 3] override fun visit(value: DynamicValue): Expression = when (val result = value.concatenate()) { is IterableValue<*> -> result else -> DynamicValue(result.unwrappedValue) } override fun visit(value: LambdaValue): Expression = throw UnsupportedOperationException() // None "abc" -> "noneabc" override fun visit(value: NoneValue) = value.concatenate() // Appends the result of the evaluation. override fun visit(expression: FunctionCall<*>): Expression = when (val result = expression.eval()) { is Expression -> result.append(other) else -> result.concatenate() } /** * @throws UnsupportedOperationException there is no way a composed expression could be appended to another expression */ override fun visit(expression: ComposedExpression): Expression = throw UnsupportedOperationException() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/visitor/EvalExpressionVisitor.kt ================================================ package com.quarkdown.core.function.expression.visitor import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.expression.append import com.quarkdown.core.function.expression.eval import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.EnumValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.InlineMarkdownContentValue import com.quarkdown.core.function.value.LambdaValue import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.Value /** * An [ExpressionVisitor] that evaluates an expression into a single static value, * which can be used as an input for another function call. */ class EvalExpressionVisitor : ExpressionVisitor> { // Static values: the evaluation is the value itself. override fun visit(value: StringValue) = value override fun visit(value: NumberValue) = value override fun visit(value: BooleanValue) = value override fun visit(value: OrderedCollectionValue<*>) = value override fun visit(value: UnorderedCollectionValue<*>) = value override fun visit(value: GeneralCollectionValue<*>) = value override fun visit(value: PairValue<*, *>) = value override fun visit(value: DictionaryValue<*>) = value override fun visit(value: EnumValue) = value override fun visit(value: ObjectValue<*>) = value override fun visit(value: MarkdownContentValue) = value override fun visit(value: InlineMarkdownContentValue) = value override fun visit(value: NodeValue) = value override fun visit(value: DynamicValue) = value override fun visit(value: LambdaValue) = value override fun visit(value: NoneValue) = value // When used as an input value for another function call, // the output type of the function call must be an InputValue. override fun visit(expression: FunctionCall<*>) = expression.execute() override fun visit(expression: ComposedExpression): Value<*> { if (expression.expressions.isEmpty()) { throw IllegalStateException("Composed expression has no sub-expressions") } // Creates a single expression out of multiple ones // by appending them to each other. var merged = expression.expressions.first() expression.expressions.asSequence().drop(1).forEach { merged = merged.append(it) } // The value of the built expression. return merged.eval() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/expression/visitor/ExpressionVisitor.kt ================================================ package com.quarkdown.core.function.expression.visitor import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.EnumValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.InlineMarkdownContentValue import com.quarkdown.core.function.value.LambdaValue import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue /** * A visitor for different kinds of [Expression]. * @param T output type of the `visit` methods * @see Expression * @see EvalExpressionVisitor * @see AppendExpressionVisitor */ interface ExpressionVisitor { fun visit(value: StringValue): T fun visit(value: NumberValue): T fun visit(value: BooleanValue): T fun visit(value: OrderedCollectionValue<*>): T fun visit(value: UnorderedCollectionValue<*>): T fun visit(value: GeneralCollectionValue<*>): T fun visit(value: PairValue<*, *>): T fun visit(value: DictionaryValue<*>): T fun visit(value: EnumValue): T fun visit(value: ObjectValue<*>): T fun visit(value: MarkdownContentValue): T fun visit(value: InlineMarkdownContentValue): T fun visit(value: NodeValue): T fun visit(value: DynamicValue): T fun visit(value: LambdaValue): T fun visit(value: NoneValue): T fun visit(expression: FunctionCall<*>): T fun visit(expression: ComposedExpression): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/Library.kt ================================================ package com.quarkdown.core.function.library import com.quarkdown.core.context.Context import com.quarkdown.core.function.Function import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.pipeline.PipelineHooks /** * A bundle of functions that can be called from a Quarkdown source. * @param name name of the library * @param functions functions the library makes available to call * @param onLoad optional action to run when the library is loaded in a context. Returns an optional value to be used as the result of loading the library * @param hooks optional actions to run after each stage of a pipeline where this library is registered in has been completed */ data class Library( val name: String, val functions: Set>, val onLoad: ((Context) -> OutputValue<*>)? = null, val hooks: PipelineHooks? = null, ) { /** * @return a copy of this library with the given pipeline hooks attached */ fun withHooks(hooks: PipelineHooks) = copy(hooks = hooks) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/LibraryExporter.kt ================================================ package com.quarkdown.core.function.library /** * A compacter of a library project into a single [Library] object. * A library project must contain one class implementing this interface. */ interface LibraryExporter { /** * The library to export and hand to the pipeline. */ val library: Library companion object { /** * Loads libraries from the given exporters. * @param exporters library exporters * @return set of exported libraries from the given exporters */ fun exportAll(vararg exporters: LibraryExporter): Set = exporters.map { it.library }.toSet() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/LibraryRegistrant.kt ================================================ package com.quarkdown.core.function.library import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.function.value.OutputValue /** * Component that is responsible for registering libraries in a pipeline's [Context], * in order to be looked up later. * @param context context to push libraries to */ class LibraryRegistrant( private val context: MutableContext, ) { /** * Registers a new single library, allowing it to be looked up by functions * and its [Library.onLoad] action is executed. * @param library library to register */ fun register(library: Library): OutputValue<*>? { context.libraries += library return library.onLoad?.invoke(context) } /** * Registers a new set of libraries, allowing them to be looked up by functions. * and their [Library.onLoad] action is executed. * @param libraries libraries to register */ fun registerAll(libraries: Collection) { libraries.forEach(::register) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/loader/FunctionLibraryLoader.kt ================================================ package com.quarkdown.core.function.library.loader import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.reflect.KFunctionAdapter import com.quarkdown.core.function.value.OutputValue import kotlin.reflect.KFunction /** * A Quarkdown function that can be exported via a [FunctionLibraryLoader]. */ typealias ExportableFunction = KFunction> /** * Creates a library from a single Kotlin function. * @see KFunctionAdapter */ class FunctionLibraryLoader : LibraryLoader { override fun load(source: ExportableFunction) = Library(source.name, setOf(KFunctionAdapter(source))) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/loader/LibraryLoader.kt ================================================ package com.quarkdown.core.function.library.loader import com.quarkdown.core.function.library.Library /** * Loads libraries from a generic source. * @param S type of source to extract a library from * @see com.quarkdown.core.function.reflect.loaders */ interface LibraryLoader { /** * Loads a library from a source. * @param source source to extract the library from * @return the extracted library */ fun load(source: S): Library } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/loader/MultiFunctionLibraryLoader.kt ================================================ package com.quarkdown.core.function.library.loader import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.module.QuarkdownModule /** * Creates a library from a set of Kotlin functions exported in a [QuarkdownModule]. * @param name name to assign to the library * @see FunctionLibraryLoader */ class MultiFunctionLibraryLoader( private val name: String, ) : LibraryLoader { override fun load(source: QuarkdownModule): Library = MultiLibraryLoader(this.name, FunctionLibraryLoader()) .load(source) /** * Creates a library from a set of Kotlin functions exported in multiple [QuarkdownModule]s. */ fun load(vararg sources: QuarkdownModule): Library = load(QuarkdownModule(*sources)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/loader/MultiLibraryLoader.kt ================================================ package com.quarkdown.core.function.library.loader import com.quarkdown.core.function.library.Library /** * A [LibraryLoader] that loads a library from a set of sources at once. * @param name name to assign to the library * @param loader strategy to load libraries from a single source with */ class MultiLibraryLoader( private val name: String, private val loader: LibraryLoader, ) : LibraryLoader> { override fun load(source: Set): Library = Library( this.name, source .asSequence() .flatMap { loader.load(it).functions } .toSet(), ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/library/module/QuarkdownModule.kt ================================================ package com.quarkdown.core.function.library.module import com.quarkdown.core.function.library.loader.ExportableFunction import com.quarkdown.core.function.library.loader.MultiFunctionLibraryLoader /** * A subsection of Quarkdown functions that can be exported via a [MultiFunctionLibraryLoader]. * * While this class might seem redundant in place of a typealias, * having an actual class makes it easier and more robust on Quarkdoc[^1]'s side to identify modules. * * [^1]: Quarkdoc is Quarkdown's documentation generator, based on Dokka. See the `quarkdown-quarkdoc` module. * * @param functions the functions to export in the module */ class QuarkdownModule( functions: Set, ) : HashSet(functions) { /** * Creates a [QuarkdownModule] that wraps multiple [QuarkdownModule]s, joining their functions into a single module. * The identity of the submodules is lost in the process. * @param modules the modules to include */ constructor(vararg modules: QuarkdownModule) : this(modules.flatMap { it.asSequence() }.toSet()) operator fun plus(other: QuarkdownModule): QuarkdownModule = QuarkdownModule(this + other) } /** * Creates a [QuarkdownModule] from a set of Kotlin functions. * @param functions the functions to export in the module */ fun moduleOf(vararg functions: ExportableFunction): QuarkdownModule = setOf(*functions).let(::QuarkdownModule) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/DynamicValueConverter.kt ================================================ package com.quarkdown.core.function.reflect import com.quarkdown.core.context.Context import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.error.NoSuchElementException import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.InputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.factory.ValueFactory import java.lang.reflect.InvocationTargetException import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.full.declaredFunctions import kotlin.reflect.full.findAnnotations import kotlin.reflect.full.functions import kotlin.reflect.full.isSubclassOf /** * A converter of a value that potentially holds any type (its value is stored as a plain string) * to a specific, statically defined [Value] type that can be used as an input for a function call argument. * @param value the dynamic value to convert to a typed value */ class DynamicValueConverter( private val value: DynamicValue, ) { /** * @param type target type to convert this dynamic value to. * This type is unwrapped (e.g. if [type] is `String`, the output is of type `StringValue`) * @param context context to evaluate the value for * @return a new typed [InputValue], automatically determined from [type], or `null` if it could not be converted * @throws IllegalArgumentException if the value could not be converted to the target type or if [context] is required and it's `null` * @throws NoSuchElementException if the value could not be converted to an enum entry */ @Suppress("UNCHECKED_CAST") fun convertTo( type: KClass<*>, context: Context?, ): InputValue<*>? { val raw = value.unwrappedValue ?: return null // If the target type is dynamic, do nothing. // For instance, custom functions defined from a Quarkdown function have dynamic-type parameters. if (type.isSubclassOf(DynamicValue::class)) { return value } // Special treatment for enum values. if (type.isSubclassOf(Enum::class)) { // Enum.values() function lookup. val valuesFunction = type.functions.first { it.name == "values" } as KFunction>> val values = valuesFunction.call() return ValueFactory.enum(raw, values) ?: throw NoSuchElementException(element = raw, values) } // Gets ValueFactory methods annotated with @FromDynamicType(X::class), // and the one with a matching type is invoked. for (function in ValueFactory::class.declaredFunctions) { val annotations = function.findAnnotations() val from = annotations.find { type.isSubclassOf(it.unwrappedType) } ?: continue // The factory method is suitable. Invoking it. return try { when { // Fetch the context from the function call if it's required. from.requiresContext -> { if (context == null) { throw IllegalStateException("Function call does not have an attached context") } function.call(ValueFactory, raw, context) } else -> function.call(ValueFactory, raw) } as InputValue<*>? } catch (e: InvocationTargetException) { throw e.cause ?: e } } throw IllegalArgumentException("Cannot convert DynamicValue to type $type") } } /** * When a [ValueFactory] method is marked with this annotation, it is a candidate for type conversion from a [DynamicValue]. * @param unwrappedType when an object matches this type, the function is suitable for invocation * @param requiresContext whether the factory method requires the [FunctionCall]'s context as an argument * @see ValueFactory */ @Target(AnnotationTarget.FUNCTION) @Repeatable annotation class FromDynamicType( val unwrappedType: KClass<*>, val requiresContext: Boolean = false, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/InjectedValue.kt ================================================ package com.quarkdown.core.function.reflect import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.Context import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.value.InputValue import com.quarkdown.core.function.value.ObjectValue import kotlin.reflect.KClass import kotlin.reflect.full.isSubclassOf /** * Utility for injected argument values. * @see FunctionParameter.isInjected * @see com.quarkdown.core.function.call.binding.InjectedArgumentsBinder */ object InjectedValue { /** * Generates a value to inject to a function parameter that expects a type of [type]. * * Supported types: * - [Context]: injects the context of the function call * - [FunctionCallNode]: injects the source node of the function call * - [FunctionCall]: injects the function call itself * * @param type type of the target parameter to inject value to. * @param call function call to extract injectable data from * @return the function-call-ready value to inject * @throws IllegalArgumentException if the target type is not injectable */ fun fromType( type: KClass<*>, call: FunctionCall<*>, ): InputValue<*> = when { type.isSubclassOf(Context::class) -> ObjectValue(call.context) type.isSubclassOf(FunctionCallNode::class) -> ObjectValue(call.sourceNode) type.isSubclassOf(FunctionCall::class) -> ObjectValue(call) else -> throw IllegalArgumentException("Cannot inject a value to type $type") } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/KFunctionAdapter.kt ================================================ package com.quarkdown.core.function.reflect import com.quarkdown.core.function.Function import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.binding.ArgumentBindings import com.quarkdown.core.function.call.validate.FunctionCallValidator import com.quarkdown.core.function.error.FunctionCallRuntimeException import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.reflect.annotation.NoAutoArgumentUnwrapping import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType import com.quarkdown.core.function.reflect.annotation.toValidator import com.quarkdown.core.function.value.InputValue import com.quarkdown.core.function.value.None import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.error.PipelineException import java.lang.reflect.InvocationTargetException import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation /** * A Quarkdown [Function] adapted from a regular Kotlin [KFunction]. * @param function Kotlin function to adapt */ class KFunctionAdapter>( private val function: KFunction, ) : Function { /** * If the [Name] annotation is present on [function], the Quarkdown function name is set from there. * Otherwise, it is [function]'s original name. */ override val name: String get() = function.findAnnotation()?.name ?: function.name @Suppress("UNCHECKED_CAST") override val parameters: List> get() = function.parameters.map { FunctionParameter( // If @Name is present, a custom name is set. name = it.findAnnotation()?.name ?: it.name ?: "", type = it.type.classifier as KClass>, index = it.index, isOptional = it.isOptional, isInjected = it.hasAnnotation(), isNullable = it.type.isMarkedNullable, ) } override val validators: List> get() = buildList { function.findAnnotation()?.toValidator()?.let(::add) function.findAnnotation()?.toValidator()?.let(::add) } override val invoke: (ArgumentBindings, FunctionCall) -> T get() = { bindings, call -> val args = bindings.asSequence().associate { (parameter, argument) -> // Corresponding KParameter. val param = function.parameters[parameter.index] // The argument is unwrapped unless the value class specifies not to. // An example of a disabled unwrapping is DynamicValue, which is used to pass dynamically typed values as-is. val arg = argument.value.let { if (it::class.hasAnnotation()) it else it.unwrappedValue } // Quarkdown's None becomes Kotlin's null for nullable parameters. param to arg.takeUnless { arg is None && param.type.isMarkedNullable } } // Call the KFunction. try { function.callBy(args) } catch (e: InvocationTargetException) { // Exceptions thrown within the called function are converted to Quarkdown exceptions // and handled accordingly by the pipeline's function expander component. Log.debug("(expected, received): " + args.map { it.key.type to it.value }) // If the exception comes from a nested function call, it is rethrown to go up the stack. throw e.targetException as? PipelineException ?: FunctionCallRuntimeException(call, e.targetException) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/ReflectionUtils.kt ================================================ package com.quarkdown.core.function.reflect import kotlin.reflect.full.declaredMembers /** * General utilities for Kotlin reflection. */ object ReflectionUtils { /** * @param name name of the constant, case-insensitive * @param T class to extract the constant from **and** the value type * @return value of the constant with the given name if found, `null` otherwise */ inline fun getConstantByName(name: String): T? = T::class.declaredMembers.find { it.name.equals(name, ignoreCase = true) }?.call() as? T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/annotation/Injected.kt ================================================ package com.quarkdown.core.function.reflect.annotation /** * When a library function parameter is annotated with `@Injected`, its value is not supplied by a function call * but rather automatically injected by [com.quarkdown.core.function.call.binding.InjectedArgumentsBinder]. */ @Target(AnnotationTarget.VALUE_PARAMETER) annotation class Injected ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/annotation/Name.kt ================================================ package com.quarkdown.core.function.reflect.annotation /** * If a library member is annotated with this, a custom name is set instead of its original name. * For example: * - `fun someFunction() = ...` * can be invoked in Quarkdown via .someFunction. * - `@Name("somefunction") fun someFunction() = ...` * can be invoked in Quarkdown via .somefunction. * - `fun func(@Name("someparam") someParam: String) = ...` * can be invoked in Quarkdown via `.func someparam:{...}` * * @param name custom function name */ @Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER) annotation class Name( val name: String, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/annotation/NoAutoArgumentUnwrapping.kt ================================================ package com.quarkdown.core.function.reflect.annotation import com.quarkdown.core.function.reflect.KFunctionAdapter import com.quarkdown.core.function.value.Value /** * When invoking a function via [KFunctionAdapter], [Value] arguments are automatically unwrapped to their raw value, * unless this annotation is present on the [Value] subclass. */ @Target(AnnotationTarget.CLASS) annotation class NoAutoArgumentUnwrapping ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/annotation/OnlyForDocumentType.kt ================================================ package com.quarkdown.core.function.reflect.annotation import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.call.validate.DocumentTypeFunctionCallValidator import com.quarkdown.core.function.value.OutputValue /** * When a library function is annotated with this annotation, it can only be called if the document adopts one of the given document types. * @param types allowed document types */ @Target(AnnotationTarget.FUNCTION) annotation class OnlyForDocumentType( vararg val types: DocumentType, ) /** * When a library function is annotated with this annotation, it can only be called if the document adopts none of the given document types. * This is the opposite of [OnlyForDocumentType]. * @param types allowed document types */ @Target(AnnotationTarget.FUNCTION) annotation class NotForDocumentType( vararg val types: DocumentType, ) /** * Converts an [OnlyForDocumentType] annotation to a [DocumentTypeFunctionCallValidator] */ fun > OnlyForDocumentType.toValidator() = DocumentTypeFunctionCallValidator(types.toSet()) /** * Converts an [NotForDocumentType] annotation to a [DocumentTypeFunctionCallValidator] */ fun > NotForDocumentType.toValidator() = DocumentTypeFunctionCallValidator(DocumentType.entries - types.toSet()) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/reflect/annotation/QuarkdocAnnotations.kt ================================================ package com.quarkdown.core.function.reflect.annotation // Annotations that do not have any runtime effect, but are used for documentation purposes in Quarkdoc. /** * When a library function parameter is annotated with `@LikelyBody`, * it is marked as a body parameter that usually expects a body argument ([wiki](https://quarkdown.com/wiki/syntax-of-a-function-call#block-vs-inline-function-calls)). * This does not have any runtime effect, but is rather used for documentation purposes (see Quarkdoc). */ @Target(AnnotationTarget.VALUE_PARAMETER) annotation class LikelyBody /** * When a library function parameter is annotated with `@LikelyNamed`, * it is marked as a parameter that usually expects a named argument ([wiki](https://quarkdown.com/wiki/syntax-of-a-function-call)). * This does not have any runtime effect, but is rather used for documentation purposes (see Quarkdoc). */ @Target(AnnotationTarget.VALUE_PARAMETER) annotation class LikelyNamed /** * When a library function is annotated with `@LikelyChained`, * it is marked as a parameter that usually expects to be chained ([wiki](https://quarkdown.com/wiki/syntax-of-a-function-call#chaining-calls)). * This does not have any runtime effect, but is rather used for documentation purposes (see Quarkdoc). */ @Target(AnnotationTarget.FUNCTION) annotation class LikelyChained ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/AdaptableValue.kt ================================================ package com.quarkdown.core.function.value /** * If a [Value] subclass is adaptable, it can be converted to another [Value] * in case the parameter of the function it is passed to expects a different type. * * For example, a [DictionaryValue] can be adapted to an [IterableValue], * and a [MarkdownContentValue] can be adapted to a [NodeValue]. * * @param T type of the value to adapt to */ interface AdaptableValue> { /** * Adapts the value to another type. */ fun adapt(): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/BooleanValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable boolean [Value]. */ data class BooleanValue( override val unwrappedValue: Boolean, ) : InputValue, OutputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } /** * @return [this] boolean wrapped into a [BooleanValue] */ fun Boolean.wrappedAsValue() = BooleanValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/Destructurable.kt ================================================ package com.quarkdown.core.function.value /** * Anything that can be destructured into a sequence of [Value] components. * * A common usage is with an [IterableValue], which can be destructured into its elements. * For example, a [PairValue] is an [IterableValue] that can be destructured into its two components. * * Destructuring in Quarkdown may occur, for example, in `.foreach`. * * @param T type of the components * @see IterableValue */ interface Destructurable> { /** * Components that can be the result of a destructuring operation. */ val destructurableComponents: List /** * Destructures this object into a list of components. * @param componentCount number of components to destructure * @return a list of components of size [componentCount] * @throws IllegalArgumentException if [componentCount] is greater than the number of available components */ fun destructured(componentCount: Int): List { // Ensuring the iterable has enough components to destructure. if (componentCount > destructurableComponents.size) { throw IllegalArgumentException( "Cannot destructure value: $destructurableComponents. " + "The value has ${destructurableComponents.size} components, but $componentCount were requested.", ) } return destructurableComponents.take(componentCount) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/DictionaryValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * A mutable [Map] [Value], with string keys and values of type [T]. * A dictionary can be adapted to an iterable list of key-value entries. * @param T type of values in the dictionary */ data class DictionaryValue>( override val unwrappedValue: MutableMap, ) : InputValue>, OutputValue>, AdaptableValue>> { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) override fun adapt(): IterableValue> { val pairs = unwrappedValue.entries.map { PairValue(it.key.wrappedAsValue() to it.value) } return GeneralCollectionValue(pairs) } } /** * Generates a [DictionaryValue] from key-value pairs. * @param pairs key-value pairs */ fun dictionaryOf(vararg pairs: Pair>) = DictionaryValue(mutableMapOf(*pairs)) /** * Generates a [DictionaryValue] from key-value pairs. * @param pairs key-value pairs */ fun dictionaryOf(pairs: Iterable>>) = DictionaryValue(mutableMapOf(*pairs.toList().toTypedArray())) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/DynamicValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.context.Context import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.reflect.annotation.NoAutoArgumentUnwrapping import com.quarkdown.core.function.value.output.OutputValueVisitor /** * A [Value] whose type has not been yet determined. * - This is more commonly used as an [InputValue] to represent a value written by the user * that does not have a specific type yet. * - It is also used as an [OutputValue] by functions such as the stdlib `.function`, which * returns general content that can be used as any type, depending on the needs. * @param unwrappedValue either a raw/unprocessed representation of the wrapped value (e.g. the number 5 saved as the string "5") * or simply an opaque wrapper for a generic value (e.g. a `Node`) * @param evaluationContext optional context that preserves the scope in which this value was produced. * When set (e.g. by a lambda parameter function), the output visitor uses this context * instead of its own to parse raw string content, ensuring that deferred variable references * resolve in the correct scope. * @see com.quarkdown.core.function.reflect.DynamicValueConverter */ @NoAutoArgumentUnwrapping data class DynamicValue( override val unwrappedValue: Any?, val evaluationContext: Context? = null, ) : InputValue, OutputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/EnumValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor /** * A [Value] that wraps an element from a static enum class. */ data class EnumValue( override val unwrappedValue: Enum<*>, ) : InputValue> { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) } /** * @return [this] enum wrapped into an [EnumValue] */ fun Enum<*>.wrappedAsValue() = EnumValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/GeneralCollectionValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable [Value] that contains other values of the same type. The kind of ordering is not relevant. * When exporting to a node via [com.quarkdown.core.function.value.output.node.NodeOutputValueVisitor], * this collection is simply converted to a group of nodes. * @param T the element type of the list */ data class GeneralCollectionValue>( override val unwrappedValue: Iterable, ) : IterableValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/IterableValue.kt ================================================ package com.quarkdown.core.function.value /** * A [Value] that wraps an [Iterable] collection of [Value] elements. * @param T type of the elements * @see OrderedCollectionValue * @see UnorderedCollectionValue * @see GeneralCollectionValue * @see PairValue */ interface IterableValue> : InputValue>, OutputValue>, Destructurable, Iterable { override val unwrappedValue: Iterable override fun iterator(): Iterator = unwrappedValue.iterator() override val destructurableComponents: List get() = unwrappedValue.toList() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/LambdaValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.data.Lambda /** * A [Value] that wraps an action of variable parameter count. */ data class LambdaValue( override val unwrappedValue: Lambda, ) : InputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/MarkdownContentValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.function.expression.visitor.ExpressionVisitor /** * A sub-AST that contains Markdown nodes. This is usually accepted in 'body' parameters. */ data class MarkdownContentValue( override val unwrappedValue: MarkdownContent, ) : InputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) /** * @return this content as a [NodeValue], suitable for function outputs */ fun asNodeValue(): NodeValue = NodeValue(unwrappedValue) /** * @return this Markdown content value to an [InlineMarkdownContent] value. Wrapped content is identical */ fun asInline() = InlineMarkdownContentValue(InlineMarkdownContent(unwrappedValue.children)) } /** * @return [this] Markdown content wrapped into a [MarkdownContentValue] */ fun MarkdownContent.wrappedAsValue() = MarkdownContentValue(this) /** * A sub-AST that contains Markdown nodes. This is usually accepted in 'body' parameters. */ data class InlineMarkdownContentValue( override val unwrappedValue: InlineMarkdownContent, ) : InputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) /** * @return this content as a [NodeValue], suitable for function outputs */ fun asNodeValue(): NodeValue = NodeValue(unwrappedValue) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/NodeValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.Node import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable [Node] [Value]. */ data class NodeValue( override val unwrappedValue: Node, ) : OutputValue, Expression, AdaptableValue { override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun adapt(): MarkdownContentValue = MarkdownContentValue(MarkdownContent(listOf(unwrappedValue))) } /** * @return [this] node wrapped into a [NodeValue] */ fun Node.wrappedAsValue() = NodeValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/NoneValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * Nothing. This is Quarkdown's equivalent of `null`. */ data object None /** * A value that represents a missing value. */ data object NoneValue : InputValue, OutputValue { override val unwrappedValue = None override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } /** * Whether this value represents a missing/null value, * either as a direct [NoneValue] or as a value wrapping [None], [NoneValue], or `null`. */ fun Value<*>.isNone(): Boolean = this is NoneValue || unwrappedValue.let { it == null || it is None || it is NoneValue } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/NumberValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor import kotlin.math.ceil import kotlin.math.floor /** * An immutable numeric [Value]. */ data class NumberValue( private val rawUnwrappedValue: Number, ) : InputValue, OutputValue { /** * [rawUnwrappedValue] adapted to either [Int] or [Float] depending on its value. */ override val unwrappedValue: Number = rawUnwrappedValue.let { when { it is Int || it is Long -> it // 5 -> 5 ceil(it.toFloat()) == floor(it.toFloat()) -> it.toInt() // 5.0 -> 5 else -> it // 5.2 -> 5.2 } } override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } /** * @return [this] number wrapped into a [NumberValue] */ fun Number.wrappedAsValue() = NumberValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/ObjectValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * A [Value] that wraps an element from a static enum class. */ data class ObjectValue( override val unwrappedValue: T, ) : InputValue, OutputValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/OrderedCollectionValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable [Value] that contains other values of the same type, ordered. * @param T the element type of the list */ data class OrderedCollectionValue>( override val unwrappedValue: List, ) : IterableValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } /** * @return [this] list wrapped into a [OrderedCollectionValue] */ fun > List.wrappedAsValue() = OrderedCollectionValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/PairValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable [Value] that contains two elements, which can be iterated. * When a [DictionaryValue] is iterated, it's equivalent to a list of key-value pairs. * @param F type of the first element * @param S type of the second element */ data class PairValue, S : OutputValue<*>>( private val pairUnwrappedValue: Pair, ) : IterableValue> { /** * A list of the two elements. */ override val unwrappedValue: Iterable> get() = listOf(pairUnwrappedValue.first, pairUnwrappedValue.second) override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/StringValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable string [Value]. */ data class StringValue( override val unwrappedValue: String, ) : InputValue, OutputValue, AdaptableValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) // A string value can be passed in place of Markdown content to represent plain text. override fun adapt() = InlineMarkdownContentValue( InlineMarkdownContent( buildInline { text(unwrappedValue) }, ), ) } /** * @return [this] string wrapped into a [StringValue] */ fun String.wrappedAsValue() = StringValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/UnorderedCollectionValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.visitor.ExpressionVisitor import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable [Value] that contains other values of the same type, unordered. * @param T the element type of the list */ data class UnorderedCollectionValue>( override val unwrappedValue: Set, ) : IterableValue { override fun accept(visitor: ExpressionVisitor): T = visitor.visit(this) override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } /** * @return [this] set wrapped into a [UnorderedCollectionValue] */ fun > Set.wrappedAsValue() = UnorderedCollectionValue(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/Value.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An immutable value wrapper. */ sealed interface Value { /** * The wrapped value. */ val unwrappedValue: T } /** * An immutable value wrapper that is used in function parameters and function call arguments. * When used as an [Expression], its evaluated value is the same as its static wrapped value */ sealed interface InputValue : Value, Expression /** * An immutable value wrapper that is used in function outputs. */ sealed interface OutputValue : Value { fun accept(visitor: OutputValueVisitor): O } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/VoidValue.kt ================================================ package com.quarkdown.core.function.value import com.quarkdown.core.function.value.output.OutputValueVisitor /** * An empty [Value] with no content. */ data object VoidValue : OutputValue { override val unwrappedValue = Unit override fun accept(visitor: OutputValueVisitor): O = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/data/EvaluableString.kt ================================================ package com.quarkdown.core.function.value.data import com.quarkdown.core.function.value.factory.ValueFactory /** * A [String] wrapper that, when used as a function parameter, lets [ValueFactory.evaluableString] evaluate the raw content. * This allows function calls and other scripting techniques to be used executed within the string itself, * which would otherwise be natively unsupported unless the [String] argument is inlined (not used as a block argument). * Inline string evaluation is handled directly by the parser [com.quarkdown.core.parser.BlockTokenParser]). * This is used for example in the `.code` stdlib function. * @param content unwrapped string content, already evaluated * @see ValueFactory.evaluableString */ data class EvaluableString( val content: String, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/data/Lambda.kt ================================================ package com.quarkdown.core.function.value.data import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.function.SimpleFunction import com.quarkdown.core.function.error.InvalidLambdaArgumentCountException import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.reflect.DynamicValueConverter import com.quarkdown.core.function.reflect.FromDynamicType import com.quarkdown.core.function.value.AdaptableValue import com.quarkdown.core.function.value.Destructurable import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.factory.ValueFactory private const val LAMBDA_LIBRARY_NAME = "__lambda-parameters__" data class LambdaParameter( val name: String, val isOptional: Boolean = false, ) /** * An action block with a variable parameter count. * The return type is dynamic (a snippet of raw Quarkdown code is returned), hence it is evaluated and converted to a static type. * @param parentContext context this lambda lies in * @param explicitParameters named parameters of the lambda. If not present, parameter names are automatically set to .1, .2, etc. * @param action action to perform, which takes a variable sequence of [Value]s and this lambda's own forked context as arguments * and returns the output of the lambda. */ open class Lambda( val parentContext: Context, val explicitParameters: List = emptyList(), val action: (List>, Context) -> OutputValue<*>, ) { /** * Registers the arguments in the context, which can be accessed as function calls. * @param arguments arguments of the lambda action */ private fun createLambdaParametersLibrary(arguments: List>) = Library( LAMBDA_LIBRARY_NAME, functions = arguments .mapIndexed { index, argument -> val parameterName = explicitParameters.getOrNull(index)?.name ?: (index + 1).toString() SimpleFunction( parameterName, parameters = emptyList(), ) { _, call -> // Value associated to the lambda argument. DynamicValue(argument.unwrappedValue, evaluationContext = call.context) } }.toSet(), ) /** * Checks if the amount of arguments matches the amount of expected parameters. * @param arguments arguments of the lambda action */ private fun isArgumentCountValid(arguments: List>): Boolean { // If no explicit parameters are present, implicit parameters are automatically set to .1, .2, etc., // hence the argument count is always valid. if (explicitParameters.isEmpty()) return true // If the amount of arguments matches the amount of mandatory parameters, the argument count is valid. val mandatoryParameterCount = explicitParameters.count { !it.isOptional } return arguments.size in mandatoryParameterCount..explicitParameters.size } /** * Invokes the lambda action with given arguments. * @param arguments arguments of the lambda action * @param callingContext optional context of the call site that triggered this lambda invocation. * When provided, its own libraries (e.g. lambda parameters, local variables) * are propagated to the forked execution context, allowing body arguments * containing dynamic references to resolve variables from the calling scope. * @param allowDestructuring if `true`, [arguments] has only 1 element which is [Destructurable], and the lambda has N>1 explicit parameters, * the argument is destructured into N parts. * For example, a dictionary may be destructured into its key and value. * @return the result of the lambda action, as an undetermined, thus dynamically-typed, value */ fun invokeDynamic( arguments: List>, callingContext: Context? = null, allowDestructuring: Boolean = true, ): OutputValue<*> { // Destructuring if (allowDestructuring && explicitParameters.size > 1) { // The lambda is invoked with the first N destructured components. (arguments.singleOrNull() as? Destructurable<*>) ?.let { return invokeDynamic(it.destructured(componentCount = explicitParameters.size), callingContext) } } // Check if the amount of arguments matches the amount of expected parameters. // In case parameters are not present, placeholders are automatically set to // .1, .2, etc., similarly to Kotlin's 'it' argument. // This replacement is handled by ValueFactory.lambda if (!isArgumentCountValid(arguments)) { throw InvalidLambdaArgumentCountException(explicitParameters.size, arguments.size) } // The actual arguments to pass to the lambda action, based on the given `arguments`. val actualArguments = when { arguments.size < explicitParameters.size -> { // If the remaining parameters are optional, fill the remaining parameters with 'none' placeholder values. arguments + List(explicitParameters.size - arguments.size) { NoneValue } } else -> { arguments } } // Create a new independent context, copy of the parent one, to execute the lambda block in. // Upon invocation, the context is filled with the arguments information, // whose values can be retrieved as function calls. val context = parentContext.fork() // Register the arguments in the context, which can be accessed as function calls. // Lambda parameters are added first so they take priority over the calling context's declarations. context.libraries += createLambdaParametersLibrary(actualArguments) // Propagate the calling scope's own libraries (e.g. its lambda parameters, locally defined variables) // so that dynamic value references passed as body arguments can resolve variables from the calling scope. // These are added after the lambda parameters so that the lambda's own parameters shadow any // same-named declarations from the calling context. if (callingContext is MutableContext) { context.libraries += callingContext.libraries } // The result of the lambda action is processed. return action(actualArguments, context) } /** * @see invokeDynamic */ fun invokeDynamic(vararg arguments: Value<*>): OutputValue<*> = invokeDynamic(arguments.toList()) /** * Invokes the lambda action with given arguments and converts it to a static type. * @param values arguments of the lambda action * @param T **unwrapped** type to convert the resulting dynamic value to. * This type must appear in a [FromDynamicType] annotation on a [ValueFactory] method * @param V **wrapped** value type (which wraps [T]) to convert the resulting dynamic value to * @return the result of the lambda action, as a statically typed value */ inline fun > invoke(vararg values: Value<*>): V { // Invoke the lambda action and convert the result to a static type. val result = invokeDynamic(*values) return when (result) { is V -> result is DynamicValue -> DynamicValueConverter(result).convertTo(T::class, parentContext) is AdaptableValue<*> -> result.adapt() else -> result } as? V ?: throw IllegalArgumentException("Unexpected lambda result: expected ${V::class}, found ${result::class}") } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/data/Range.kt ================================================ package com.quarkdown.core.function.value.data import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NumberValue /** * Default lower bound index of a [Range] whose `start` value is `null`, when converted to a collection. * This can also be seen as `N` in _arrays start from `N`_. * @see Range.toCollection */ private const val DEFAULT_LOWER_BOUND_INDEX = 1 /** * Represents a range of numbers, which can also be iterated through. * @property start start of the range (inclusive). If `null`, the range is infinite on the left end * @property end end of the range (inclusive). If `null`, the range is infinite on the right end. [end] > [start] */ data class Range( val start: Int?, val end: Int?, ) : Iterable { /** * @return whether the range is infinite, i.e. both [start] and [end] are `null` */ val isInfinite: Boolean get() = start == null && end == null /** * @param lowerBound lower bound of the range, in case [start] is `null` * @param upperBound upper bound of the range, in case [end] is `null` * @return this range as an [IntRange], with [start] and [end] replaced by [lowerBound] and [upperBound] respectively if they are `null` */ private fun toIntRange( lowerBound: Int, upperBound: Int, ) = IntRange(start ?: lowerBound, end ?: upperBound) /** * @return this range as an iterable collection value */ fun toCollection(): IterableValue = GeneralCollectionValue(this) /** * @return a new iterator for this range. * If this is open on the left end, it starts from [DEFAULT_LOWER_BOUND_INDEX]. * @throws IllegalStateException if [end] is `null` */ override fun iterator(): Iterator { if (end == null) { throw IllegalStateException("Cannot iterate through an endless range.") } return toIntRange(lowerBound = DEFAULT_LOWER_BOUND_INDEX, upperBound = end) .asSequence() .map(::NumberValue) .iterator() } /** * @param bounds range to check if this range is in. Both its `start` and `end` values must be non-null * @param lowerBound lower bound of [bounds] to use if [start] is `null` * @param upperBound upper bound of [bounds] to use if [end] is `null` * @return whether this range is contained within [bounds] */ fun isIn( bounds: Range, lowerBound: Int = bounds.start!!, upperBound: Int = bounds.end!!, ): Boolean { val start = start ?: lowerBound val end = end ?: upperBound return start >= bounds.start!! && end <= bounds.end!! } /** * Checks if this range (bounds) contains another range. * Both [start] and [end] of the bounds range must be non-null. * @param range range to check if this range contains * @return whether this range contains [range]. */ operator fun contains(range: Range) = range.isIn(this) override fun toString() = "${start ?: ""}..${end ?: ""}" companion object { /** * An infinite range on both ends. */ val INFINITE = Range(null, null) } } /** * @param range range to get the sublist from * @return sublist of [this] list, starting from [range]'s start (starting from 1) and ending at [range]'s end (both inclusive). * If any of the bounds is `null`, it is replaced by the list's start or end index respectively */ fun List.subList(range: Range): List = subList(range.start?.minus(DEFAULT_LOWER_BOUND_INDEX) ?: 0, range.end ?: this.size) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/factory/IllegalRawValueException.kt ================================================ package com.quarkdown.core.function.value.factory import com.quarkdown.core.ILLEGAL_TYPE_CONVERSION_EXIT_CODE import com.quarkdown.core.pipeline.error.PipelineException /** * An exception thrown when a dynamic value cannot be converted to a static type via a [ValueFactory] method. * @param raw raw value that could not be converted */ class IllegalRawValueException( message: String, raw: Any, ) : PipelineException( "$message: $raw", ILLEGAL_TYPE_CONVERSION_EXIT_CODE, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/factory/ValueFactory.kt ================================================ package com.quarkdown.core.function.value.factory import com.quarkdown.core.RUNTIME_ERROR_EXIT_CODE import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.base.inline.PlainTextNode import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.expression.Expression import com.quarkdown.core.function.expression.SafeExpression import com.quarkdown.core.function.expression.eval import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.function.reflect.FromDynamicType import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.EnumValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.InlineMarkdownContentValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.LambdaValue import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.data.EvaluableString import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.data.LambdaParameter import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.factory.ValueFactory.eval import com.quarkdown.core.function.value.factory.ValueFactory.expression import com.quarkdown.core.function.value.factory.ValueFactory.iterable import com.quarkdown.core.function.value.factory.ValueFactory.lambda import com.quarkdown.core.function.value.factory.ValueFactory.range import com.quarkdown.core.function.value.factory.ValueFactory.safeExpression import com.quarkdown.core.function.value.factory.ValueFactory.size import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.patterns.PatternHelpers import com.quarkdown.core.misc.color.Color import com.quarkdown.core.misc.color.decoder.decode import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.error.UnattachedPipelineException import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.pipeline.stage.thenOptionally import com.quarkdown.core.pipeline.stages.FunctionCallExpansionStage import com.quarkdown.core.pipeline.stages.ParsingStage import com.quarkdown.core.util.iterator import com.quarkdown.core.util.node.conversion.list.MarkdownListToCollectionValue import com.quarkdown.core.util.node.conversion.list.MarkdownListToDictionaryValue /** * Suffix that marks a lambda parameter as optional. * @see ValueFactory.lambda */ private const val LAMBDA_OPTIONAL_PARAMETER_SUFFIX = '?' /** * Prefix that forces a generic expression to be parsed as a lambda block. * @see ValueFactory.expression */ private const val EXPRESSION_FORCE_LAMBDA_PREFIX = "@lambda " /** * Factory of [Value] wrappers from raw data. * @see com.quarkdown.core.function.reflect.FromDynamicType * @see com.quarkdown.core.function.reflect.DynamicValueConverter.convertTo */ object ValueFactory { /** * @param raw raw value to convert to a string value * @return a new string value that wraps [raw] */ @FromDynamicType(String::class) fun string(raw: Any) = StringValue(raw.toString()) /** * @param raw raw value to convert to a number value * @return a new number value that wraps [raw]'s integer (if possible) or float value * @throws IllegalRawValueException if [raw] is not a valid numeric value */ @FromDynamicType(Number::class) fun number(raw: Any): NumberValue = when (raw) { is Number -> { NumberValue(raw) } else -> { raw .toString() .let { it.toIntOrNull() ?: it.toFloatOrNull() } ?.let { NumberValue(it) } ?: throw IllegalRawValueException("Not a numeric value", raw) } } /** * @param raw raw value to convert to a boolean value. * `true`,`yes` -> `true`, * `false`,`no` -> `false` * @return a new boolean value that wraps [raw]'s boolean value, or `null` if [raw] does not represent a boolean * @throws IllegalRawValueException if [raw] is not a valid boolean value */ @FromDynamicType(Boolean::class) fun boolean(raw: Any): BooleanValue = when (raw) { is Boolean -> { BooleanValue(raw) } else -> { when (raw.toString().lowercase()) { "true", "yes" -> BooleanValue(true) "false", "no" -> BooleanValue(false) else -> throw IllegalRawValueException("Not a valid boolean value", raw) } } } /** * @param raw raw value to convert to a range value. * The format is `x..y`, where `x` and `y` are integers that specify start and end of the range. * Both start and end can be omitted to represent an open/infinite value on that end. * @return a new range value that wraps the parsed content of [raw] * @throws IllegalRawValueException if the value is an invalid range * @see iterable */ @FromDynamicType(Range::class) fun range(raw: Any): ObjectValue { if (raw is Range) return ObjectValue(raw) val rawString = raw.toString() // Matches 'x..y', where both x and y are optional integers. val regex = "(\\d+)?\\.\\.(\\d+)?".toRegex() // If the raw value does not represent a range, an error is thrown. if (!regex.matches(rawString)) { throw IllegalRawValueException("Invalid range", raw) } val groups = regex .find(rawString) ?.groupValues ?.asSequence() ?.iterator(consumeAmount = 1) // Start of the range. If null (= not present), the range is open on the left end. val start = groups?.next() // End of the range. If null (= not present), the range is open on the right end. val end = groups?.next() // Indexes start from 1: // 2..5 maps to Range(1, 4) val range = Range( start?.toIntOrNull(), end?.toIntOrNull(), ) return ObjectValue(range) } /** * @param raw raw value to convert to a size value. * The format is `Xunit`, where `X` is a number (integer or floating point) * and `unit` is one of the following: `px`, `pt`, `cm`, `mm`, `in`. If not specified, `px` is assumed. * @return a new size value that wraps the parsed content of [raw]. * @throws IllegalRawValueException if the value is an invalid size */ @FromDynamicType(Size::class) fun size(raw: Any): ObjectValue { if (raw is Size) return ObjectValue(raw) // All possible unit symbols. val symbolsRegex = Size.Unit.entries.joinToString("|") { it.symbol } // Matches value and unit, e.g. 10px, 12.5cm, -3in. val regex = "^(-?\\d+(?:\\.\\d+)?)($symbolsRegex)?$".toRegex() val groups = regex .find(raw.toString()) ?.groupValues ?.asSequence() ?.iterator(consumeAmount = 1) // The value, which is required. val value = groups?.next()?.toDoubleOrNull() ?: throw IllegalRawValueException("Invalid size", raw) // The unit, which is optional and defaults to pixels. val rawUnit = groups.next() val unit = Size.Unit.entries.find { it.symbol.equals(rawUnit, ignoreCase = true) } ?: Size.Unit.PIXELS return ObjectValue(Size(value, unit)) } /** * @param raw raw value to convert to a collection of sizes. * @see size for the treatment of each size * @throws IllegalRawValueException if the raw value contains a different amount of sizes than 1, 2 or 4, * of if any of those values is an invalid size */ @FromDynamicType(Sizes::class) fun sizes(raw: Any): ObjectValue { if (raw is Sizes) return ObjectValue(raw) val parts = raw.toString().split("\\s+".toRegex()) val iterator = parts.iterator() return ObjectValue( when (parts.size) { // Single size: all sides are the same. 1 -> { Sizes(all = size(iterator.next()).unwrappedValue) } // Two sizes: vertical and horizontal. 2 -> { Sizes( vertical = size(iterator.next()).unwrappedValue, horizontal = size(iterator.next()).unwrappedValue, ) } // Four sizes: top, right, bottom, left. 4 -> { Sizes( top = size(iterator.next()).unwrappedValue, right = size(iterator.next()).unwrappedValue, bottom = size(iterator.next()).unwrappedValue, left = size(iterator.next()).unwrappedValue, ) } else -> { throw IllegalRawValueException("Invalid top-right-bottom-left sizes", raw) } }, ) } /** * @param raw raw value to convert to a color value, case-insensitive. * Can be a hex value starting by `#` (e.g. `#FF0000`) or a color name (e.g. `red`). * @return a new color value that wraps the parsed content of [raw] * @throws IllegalRawValueException if the value is an invalid color */ @FromDynamicType(Color::class) fun color(raw: Any): ObjectValue { if (raw is Color) return ObjectValue(raw) return Color.decode(raw.toString())?.let(::ObjectValue) ?: throw IllegalRawValueException("Not a valid color", raw) } /** * @param raw raw value to convert to an enum value * @param values enum values pool to pick the output value from * @return the value whose name matches (ignoring case and with `_`s removed) with [raw], or `null` if no match is found */ @FromDynamicType(Enum::class) fun enum( raw: Any, values: Array>, ): EnumValue? = when (raw) { is Enum<*> -> { EnumValue(raw) } else -> { values .find { it.quarkdownName.equals(raw.toString(), ignoreCase = true) } ?.let { EnumValue(it) } } } /** * Generates an [EvaluableString]. * Contrary to [String], an [EvaluableString] natively supports function calls and scripting evaluation. * @param raw raw value to convert to a string expression * @param context context to evaluate the raw value in * @return a new string expression value that wraps the evaluated content of [raw] * @see eval for the evaluation process */ @FromDynamicType(EvaluableString::class, requiresContext = true) fun evaluableString( raw: Any, context: Context, ): ObjectValue = ObjectValue( EvaluableString( eval(raw.toString(), context).unwrappedValue.toString(), ), ) /** * @param lexer lexer to use to tokenize content * @param context context to retrieve the pipeline from, which allows parsing and function expansion * @param expandFunctionCalls whether enqueued function calls should be expanded instantly * @return a new value that wraps the root of the produced AST */ fun markdown( lexer: Lexer, context: Context, expandFunctionCalls: Boolean, ): MarkdownContentValue { // Retrieving the pipeline linked to the context. val pipeline = context.attachedPipeline ?: throw UnattachedPipelineException() context as MutableContext val sharedData = SharedPipelineData(pipeline = pipeline, context = context) val parsing: PipelineStage, AstRoot> = ParsingStage thenOptionally FunctionCallExpansionStage.takeIf { expandFunctionCalls } val root: AstRoot = parsing.execute(lexer.tokenize(), sharedData) return MarkdownContentValue(MarkdownContent(root.children)) } /** * @param raw string input to parse into a sub-AST * @param context context to retrieve the pipeline from, which allows tokenization and parsing of the input * @return a new value that wraps the root of the produced AST, containing both block and inline content */ @FromDynamicType(MarkdownContent::class, requiresContext = true) fun blockMarkdown( raw: Any, context: Context, ): MarkdownContentValue = when (raw) { is MarkdownContent -> { MarkdownContentValue(raw) } is Node -> { MarkdownContentValue(MarkdownContent(listOf(raw))) } is DynamicValue if raw.unwrappedValue is String -> { blockMarkdown(raw.unwrappedValue, context) } else -> { markdown( context.flavor.lexerFactory.newBlockLexer(raw.toString()), context, expandFunctionCalls = true, ) } } /** * @param raw string input to parse into a sub-AST * @param context context to retrieve the pipeline from, which allows tokenization and parsing of the input * @return a new value that wraps the root of the produced AST, containing inline content only */ @FromDynamicType(InlineMarkdownContent::class, requiresContext = true) fun inlineMarkdown( raw: Any, context: Context, ): InlineMarkdownContentValue = when (raw) { is InlineMarkdownContent -> { InlineMarkdownContentValue(raw) } is Node -> { InlineMarkdownContentValue(InlineMarkdownContent(listOf(raw))) } is DynamicValue if raw.unwrappedValue is String -> { inlineMarkdown(raw.unwrappedValue, context) } else -> { markdown( context.flavor.lexerFactory.newInlineLexer(raw.toString()), context, expandFunctionCalls = true, ).asInline() } } /** * Given a list block as a raw Markdown content, parsed and extracts it. * @param raw Markdown input to parse * @param context context to retrieve the pipeline from * @param lazyErrorMessage function that returns the error message to show if the input is not a valid Markdown list * @return the [ListBlock] contained in the Markdown input * @throws IllegalRawValueException if [raw] is not a valid Markdown list */ private fun extractList( raw: Any, context: Context, lazyErrorMessage: () -> String, ): ListBlock { val content = blockMarkdown(raw, context).unwrappedValue return content.children.singleOrNull { it !is Newline } as? ListBlock ?: throw IllegalRawValueException("${lazyErrorMessage()} (the only element must be a Markdown list)", raw) } /** * @param raw string input to parse the expression from * @param context context to retrieve the pipeline from * @return a new [IterableValue] from the raw expression. It can also be a [Range]. * @throws IllegalRawValueException if [raw] cannot be converted to an iterable * @see range */ @Suppress("UNCHECKED_CAST") @FromDynamicType(Iterable::class, requiresContext = true) fun iterable( raw: Any, context: Context, ): IterableValue<*> { when (raw) { is Range -> return raw.toCollection() is List<*> -> return OrderedCollectionValue(raw as List>) is Set<*> -> return UnorderedCollectionValue(raw as Set>) is Iterable<*> -> return GeneralCollectionValue(raw as Iterable>) } // A range is a suitable numeric iterable value. try { val range = range(raw) return range.unwrappedValue.toCollection() } catch (_: IllegalRawValueException) { // The raw value is not a range. } val rawString = raw.toString() // The expression is evaluated into an iterable. val value = expression(rawString, context)?.eval() ?: return OrderedCollectionValue(emptyList()) fun fromMarkdownList() = this.extractList(raw, context) { "Not an iterable" }.let { MarkdownListToCollectionValue.viaValueFactory(it, context).convert() } return value as? IterableValue<*> ?: (value as? DictionaryValue<*>)?.adapt() // A dictionary is an iterable of key-value pairs. ?: fromMarkdownList() // A Markdown list is a valid iterable. } /** * Converts a raw string input to a dictionary value. * A dictionary is a collection of key-value pairs, * where keys are strings and values can be expressed in two ways: * - Inline, in the format `- key: value`. * - Nested dictionaries, in the format: * ``` * - key * - value * ``` * * Dictionary example, of type `DictionaryValue>`: * ``` * - keyA: * - keyAA: valueAA * - keyB: * - keyBA: valueBA * - keyBB: valueBB * - keyC: * - keyCA: valueCA * ``` * @param raw string input to parse the dictionary from * @param context context to retrieve the pipeline from * @return a new [DictionaryValue] from the raw input * @throws IllegalRawValueException if the raw input cannot be converted to a dictionary * @see MarkdownListToDictionaryValue */ @Suppress("UNCHECKED_CAST") @FromDynamicType(Map::class, requiresContext = true) fun dictionary( raw: Any, context: Context, ): DictionaryValue<*> { (raw as? Map>)?.let { return DictionaryValue(it.toMutableMap()) } val list = this.extractList(raw, context) { "Not a dictionary" } return MarkdownListToDictionaryValue.viaValueFactory(list, context).convert() } /** * Converts a raw string input to a lambda value. * Lambda example: `param1 param2: Hello, .param1 and .param2!` * @param raw string input to parse the lambda from * @return a new [LambdaValue] from the raw input */ @FromDynamicType(Lambda::class, requiresContext = true) fun lambda( raw: Any, context: Context, ): LambdaValue { if (raw is Lambda) return LambdaValue(raw) val rawString = raw.toString() // The header is the part before the delimiter. // The header contains the sequence of lambda parameters. // If no header is present, the lambda has no parameters. val headerDelimiter = ":" // Matches a sequence of words separated by spaces or tabs, // followed by an optional '?' (makes it optional), // followed by the delimiter. val headerRegex = "^\\s*(\\w+\\??[ \\t]*)*(?=$headerDelimiter)".toRegex() val header = headerRegex.find(rawString)?.value ?: "" // The parameters are extracted from the header. val parameters: List = header .trim() .split("\\s+".toRegex()) .asSequence() .filter { it.isNotBlank() } .map { parameterName -> // If a parameter ends with '?', it is optional. val isOptional = parameterName.endsWith(LAMBDA_OPTIONAL_PARAMETER_SUFFIX) // The '?' is stripped from the parameter name. val name = if (isOptional) parameterName.dropLast(1) else parameterName LambdaParameter(name, isOptional) }.toList() // The body is the part after the delimiter, // which is the actual content of the lambda. // The body may contain placeholders wrapped in <<>> that will be replaced with actual arguments upon invocation. val body = if (header.isEmpty()) { rawString } else { // Strip the header and delimiter. rawString .substring(rawString.indexOf(headerDelimiter) + headerDelimiter.length) .trimStart() } return LambdaValue( Lambda(context, explicitParameters = parameters) { _, newContext -> // The body (as a raw code snippet) is evaluated in the context of the lambda // which is a fork of the original one. // Parameters-arguments count match is checked later. // Here we assume they match is correct. // Check Lambda#invokeDynamic for more details. eval(body, newContext) }, ) } /** * Evaluates a dynamic expression from a raw string input. * Special case: if the raw string starts with `@lambda`, the content is parsed as a [lambda] value. * This is generally an *unsafe* expression, as evaluating it may throw an [com.quarkdown.core.function.error.internal.InvalidExpressionEvalException]. * See [safeExpression] for a fallback mechanism. * @param raw either an [Expression] or a string input that may contain both static values and function calls (e.g. `"2 + 2 is .sum {2} {2}"`) * @param context context to retrieve the pipeline from * @return if [raw] is an [Expression], it is returned, otherwise returns the expression from the string input * (in the previous example: `ComposedExpression(DynamicValue("2 + 2 is "), FunctionCall(sum, 2, 2))`) */ private fun expression( raw: Any, context: Context, ): Expression? { when (raw) { is DynamicValue if raw.unwrappedValue is String -> return expression(raw.unwrappedValue, context) is Expression -> return raw } // Strip comments. val rawCode = raw.toString().replace(PatternHelpers.COMMENT.toRegex(), "") if (rawCode.isEmpty()) return DynamicValue("") // If the raw string starts with `@lambda`, the content is force-parsed as a lambda. if (rawCode.startsWith(EXPRESSION_FORCE_LAMBDA_PREFIX)) { val lambdaRaw = rawCode.removePrefix(EXPRESSION_FORCE_LAMBDA_PREFIX) return lambda(lambdaRaw, context) } // The content of the argument is tokenized to distinguish static values (string/number/...) // from nested function calls, which are also expressions. val components = markdown( lexer = context.flavor.lexerFactory.newExpressionLexer(rawCode, allowBlockFunctionCalls = true), context, expandFunctionCalls = false, ).unwrappedValue.children if (components.isEmpty()) return null /** * @param node to convert * @return an expression that matches the node type */ fun nodeToExpression(node: Node): Expression = when (node) { is PlainTextNode -> DynamicValue(node.text) // The actual type is determined later. is FunctionCallNode -> context.resolveUnchecked(node) // Function existance is checked later. else -> throw IllegalArgumentException("Unexpected node $node in expression $raw") } // Nodes are mapped to expressions. return ComposedExpression(expressions = components.map(::nodeToExpression)) } /** * Evaluates a dynamic expression from a raw string input. * This is a safe expression, meaning that if an [com.quarkdown.core.function.error.internal.InvalidExpressionEvalException] is caught while * evaluating it, the expression is discarded and a fallback expression is used. * @see expression * @see SafeExpression */ fun safeExpression( raw: Any, context: Context, fallback: () -> Expression = { blockMarkdown(raw, context) }, ): Expression { val expression = expression(raw, context) ?: return fallback() return SafeExpression(expression, fallback) } /** * Evaluates an expression from a raw string input. * @param raw string input that may contain both static values and function calls (e.g. `"2 + 2 is .sum {2} {2}"`) * @param context context to retrieve the pipeline from * @param fallback value to return if the expression is invalid or an error occurs during the evaluation. * A common example of an invalid expression evaluation is when a [NodeValue] is present in a [ComposedExpression], hence the expected output is a pure Markdown output node. * The fallback function defaults to returning a block-Markdown content node. * @return the result of the evaluation of the expression (in the previous example: `ComposedExpression(DynamicValue("2 + 2 is "), FunctionCall(sum, 2, 2))`), * or the result of the fallback function if the expression is invalid */ fun eval( raw: Any, context: Context, fallback: () -> Expression = { blockMarkdown(raw, context).asNodeValue() }, ): OutputValue<*> { val expression = safeExpression(raw, context) { // All enqueued function calls are invalidated and discarded. (context as? MutableContext)?.dequeueAllFunctionCalls() fallback() } return expression.eval().let { it as? OutputValue<*> ?: throw PipelineException( "The result of the expression is not a suitable OutputValue: $it", RUNTIME_ERROR_EXIT_CODE, ) } } /** * Executes the given block and returns its result, or `null` if the conversion fails. * @param block the block to execute, which may throw `IllegalRawValueException` * @return the result of the block, or `null` if an exception occurs */ fun > tryOrNull(block: ValueFactory.() -> T): T? = try { block() } catch (_: IllegalRawValueException) { null } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/OutputValueVisitor.kt ================================================ package com.quarkdown.core.function.value.output import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.VoidValue /** * A visitor that produces values the same type for each [com.quarkdown.core.function.value.OutputValue] type. */ interface OutputValueVisitor { fun visit(value: StringValue): T fun visit(value: NumberValue): T fun visit(value: BooleanValue): T fun visit(value: ObjectValue<*>): T fun visit(value: OrderedCollectionValue<*>): T fun visit(value: UnorderedCollectionValue<*>): T fun visit(value: GeneralCollectionValue<*>): T fun visit(value: PairValue<*, *>): T fun visit(value: DictionaryValue<*>): T fun visit(value: NodeValue): T fun visit(value: NoneValue): T fun visit(value: VoidValue): T fun visit(value: DynamicValue): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/OutputValueVisitorFactory.kt ================================================ package com.quarkdown.core.function.value.output import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.function.value.OutputValue /** * Factory that produces [OutputValueVisitor]s that map function output values ([OutputValue]) into other objects. * @param T type of the output of the visit operations */ interface OutputValueVisitorFactory { /** * @return a mapper that produces results for block function calls ([FunctionCallNode.isBlock] is true) */ fun block(): OutputValueVisitor /** * @return a mapper that produces results for inline function calls ([FunctionCallNode.isBlock] is false) */ fun inline(): OutputValueVisitor } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/node/BlockNodeOutputValueVisitor.kt ================================================ package com.quarkdown.core.function.value.output.node import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.factory.ValueFactory /** * Producer of block nodes from function output values. * @param context context of the function * @see NodeOutputValueVisitor */ class BlockNodeOutputValueVisitor( private val context: Context, ) : NodeOutputValueVisitor() { // Proxy used to convert inline values to block values. private val inline = InlineNodeOutputValueVisitor(context) /** * @return [this] node wrapped in a [Paragraph] block */ private fun Node.inParagraph() = Paragraph(listOf(this)) // Inline-to-block conversion. override fun visit(value: StringValue) = inline.visit(value).inParagraph() override fun visit(value: NumberValue) = inline.visit(value).inParagraph() override fun visit(value: BooleanValue) = inline.visit(value).inParagraph() override fun visit(value: ObjectValue<*>) = inline.visit(value).inParagraph() override fun visit(value: NoneValue) = inline.visit(value).inParagraph() // Raw Markdown code is parsed as blocks. override fun parseRaw( raw: String, context: Context?, ) = ValueFactory.blockMarkdown(raw, context ?: this.context).asNodeValue() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/node/InlineNodeOutputValueVisitor.kt ================================================ package com.quarkdown.core.function.value.output.node import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.factory.ValueFactory /** * Producer of inline nodes from function output values. * @param context context of the function * @see NodeOutputValueVisitor */ class InlineNodeOutputValueVisitor( private val context: Context, ) : NodeOutputValueVisitor() { override fun visit(value: StringValue) = Text(value.unwrappedValue) override fun visit(value: NumberValue) = Text(value.unwrappedValue.toString()) override fun visit(value: BooleanValue) = CheckBox(isChecked = value.unwrappedValue) override fun visit(value: ObjectValue<*>) = Text(value.unwrappedValue.toString()) override fun visit(value: NoneValue) = CodeSpan(value.unwrappedValue.toString()) // Raw Markdown code is parsed as inline. override fun parseRaw( raw: String, context: Context?, ) = ValueFactory.inlineMarkdown(raw, context ?: this.context).asNodeValue() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/node/NodeOutputValueVisitor.kt ================================================ package com.quarkdown.core.function.value.output.node import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.output.OutputValueVisitor /** * Producer of a [Node] output, ready to append to the AST, from a generic function output. * @see com.quarkdown.core.function.call.FunctionCallNodeExpander * @see BlockNodeOutputValueVisitor * @see InlineNodeOutputValueVisitor */ abstract class NodeOutputValueVisitor : OutputValueVisitor { private fun createListItems(value: IterableValue<*>): List = value.map { ListItem( children = listOf( // Each item is represented by its own Node output. it.accept(this), ), ) } override fun visit(value: OrderedCollectionValue<*>) = OrderedList( startIndex = 1, isLoose = false, children = createListItems(value), ) override fun visit(value: UnorderedCollectionValue<*>) = UnorderedList( isLoose = false, children = createListItems(value), ) // A general collection is just converted to a group of nodes. override fun visit(value: GeneralCollectionValue<*>) = MarkdownContent(children = value.map { it.accept(this) }) // A pair is displayed as an ordered collection of its two elements. override fun visit(value: PairValue<*, *>): Node = visit(OrderedCollectionValue(value.unwrappedValue.toList())) // A dictionary is displayed as a key-value table. override fun visit(value: DictionaryValue<*>) = buildBlock { table { column({ text("Key") }) { value.unwrappedValue.keys.forEach { key -> cell { text(key) } } } column({ text("Value") }) { value.unwrappedValue.values.forEach { value -> cell { +value.accept(this@NodeOutputValueVisitor) } } } } } override fun visit(value: NodeValue) = value.unwrappedValue override fun visit(value: VoidValue) = BlankNode // Dynamic output (e.g. produced by the stdlib function `.function`) is treated: // - If it is a suitable output value: its content is visited again with this visitor. // - If it is a collection: its items are wrapped in a GeneralCollectionValue and visited. // - Otherwise: its string content is parsed as Markdown. @Suppress("UNCHECKED_CAST") override fun visit(value: DynamicValue): Node = when (value.unwrappedValue) { is OutputValue<*> -> value.unwrappedValue.accept(this) is Iterable<*> -> GeneralCollectionValue(value.unwrappedValue as Iterable>).accept(this) is Node -> value.unwrappedValue else -> this.visit(parseRaw(value.unwrappedValue.toString(), value.evaluationContext)) } /** * When a [DynamicValue] cannot be converted to a [NodeValue], its string content is parsed as Markdown. * @param raw string content of the [DynamicValue] * @param context optional context to use for parsing instead of the visitor's own. * When a [DynamicValue] carries an [DynamicValue.evaluationContext], * it is passed here to preserve the scope in which the value was produced. * If `null`, the visitor's own context is used. * @return wrapped node parsed from the raw Markdown string */ protected abstract fun parseRaw( raw: String, context: Context? = null, ): NodeValue } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/function/value/output/node/NodeOutputValueVisitorFactory.kt ================================================ package com.quarkdown.core.function.value.output.node import com.quarkdown.core.ast.Node import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.output.OutputValueVisitor import com.quarkdown.core.function.value.output.OutputValueVisitorFactory /** * A factory that produces [OutputValueVisitor]s that map function output values * into [Node]s that can be appended to the AST. * @param context current context */ class NodeOutputValueVisitorFactory( private val context: Context, ) : OutputValueVisitorFactory { /** * @return a visitor that maps the output of a block function call into a block [Node] */ override fun block(): OutputValueVisitor = BlockNodeOutputValueVisitor(context) /** * @return a visitor that maps the output of an inline function call into an inline [Node] */ override fun inline(): OutputValueVisitor = InlineNodeOutputValueVisitor(context) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/graph/Graph.kt ================================================ package com.quarkdown.core.graph /** * A generic, immutable graph structure. * @param T the type of vertices in the graph */ interface Graph { /** * The set of vertices in the graph. */ val vertices: Set /** * The set of edges in the graph, represented as pairs of vertices. */ val edges: Set> /** * @param vertex the vertex whose neighbors are to be retrieved. * @return a sequence of neighboring vertices for the specified vertex. */ fun getNeighbors(vertex: T): Sequence /** * Adds a vertex to the graph. * @param value the value of the vertex to add * @return a new graph instance with the vertex added */ fun addVertex(value: T): Graph /** * Adds an edge from one vertex to another. * @param from the source vertex * @param to the destination vertex * @return a new graph instance with the edge added */ fun addEdge( from: T, to: T, ): Graph /** * @see addEdge */ fun addEdge(pair: Pair): Graph = addEdge(from = pair.first, to = pair.second) /** * Adds a vertex and an edge in a single operation. * @param vertex the vertex to add * @param edgeFrom the source vertex of the edge * @param edgeTo the destination vertex of the edge * @return a new graph instance with both the vertex and edge added */ fun addVertexAndEdge( vertex: T, edgeFrom: T, edgeTo: T, ): Graph = addVertex(vertex).addEdge(edgeFrom, edgeTo) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/graph/Graphs.kt ================================================ @file:Suppress("FunctionName") package com.quarkdown.core.graph /** * @return a new empty directed [Graph] */ fun DirectedGraph(): Graph = PersistentDirectedGraph() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/graph/PersistentDirectedGraph.kt ================================================ package com.quarkdown.core.graph import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentSetOf /** * An immutable directed graph using persistent data structures. * Updates return new instances that share structure with the original, * providing O(log n) time complexity per operation. * * @param T the type of vertices in the graph * @param vertices the set of vertices in the graph * @param adjacency a map from each vertex to its set of neighbors (outgoing edges) */ data class PersistentDirectedGraph( override val vertices: PersistentSet = persistentSetOf(), private val adjacency: PersistentMap> = persistentMapOf(), ) : Graph { override val edges: Set> get() = adjacency.flatMap { (from, neighbors) -> neighbors.map { from to it } }.toSet() override fun getNeighbors(vertex: T): Sequence = adjacency[vertex]?.asSequence() ?: emptySequence() override fun addVertex(value: T): PersistentDirectedGraph = copy(vertices = vertices.add(value)) override fun addEdge( from: T, to: T, ): PersistentDirectedGraph { val currentNeighbors = adjacency[from] ?: persistentSetOf() return copy(adjacency = adjacency.put(from, currentNeighbors.add(to))) } override fun addVertexAndEdge( vertex: T, edgeFrom: T, edgeTo: T, ): PersistentDirectedGraph { val currentNeighbors = adjacency[edgeFrom] ?: persistentSetOf() return copy( vertices = vertices.add(vertex), adjacency = adjacency.put(edgeFrom, currentNeighbors.add(edgeTo)), ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/graph/VisitableOnceGraph.kt ================================================ package com.quarkdown.core.graph /** * An immutable [Graph] decorator that allows visiting each vertex only once. * @param T the type of vertices in the graph * @property graph the underlying graph structure * @property visited a set of vertices that have already been visited */ data class VisitableOnceGraph( private val graph: Graph, private val visited: Set = emptySet(), ) : Graph by graph { /** * Checks if the given vertex has been visited. * @param vertex the vertex to check * @return `true` if the vertex has been visited, `false` otherwise */ private fun isVisited(vertex: T): Boolean = vertex in visited /** * Retrieves the unvisited neighbors of the specified vertex. * @param vertex the vertex whose unvisited neighbors are to be retrieved * @return a sequence of unvisited neighboring vertices */ private fun getUnvisitedNeighbors(vertex: T): Sequence = getNeighbors(vertex).filterNot(::isVisited) /** * Visits the unvisited neighbors of the specified vertex, marking each vertex as visited. * @param vertex the vertex whose neighbors are to be visited * @param onVisit callback invoked with the updated graph after visiting the neighbors * @return the yet unvisited neighboring vertices that were now visited */ fun visitNeighbors( vertex: T, onVisit: (VisitableOnceGraph) -> Unit, ): Set = getUnvisitedNeighbors(vertex) .toSet() .also { visitedVertices -> onVisit(copy(visited = visited + visitedVertices)) } override fun addVertex(value: T): VisitableOnceGraph = copy(graph = graph.addVertex(value)) override fun addEdge( from: T, to: T, ): VisitableOnceGraph = copy(graph = graph.addEdge(from, to)) override fun addEdge(pair: Pair): VisitableOnceGraph = copy(graph = graph.addEdge(pair)) override fun addVertexAndEdge( vertex: T, edgeFrom: T, edgeTo: T, ): VisitableOnceGraph = copy(graph = graph.addVertexAndEdge(vertex, edgeFrom, edgeTo)) } /** * Converts a [Graph] into a [VisitableOnceGraph] which allows visiting each vertex only once. * @receiver the graph to convert * @return a [VisitableOnceGraph] wrapping the original graph, and no initially visited vertices */ val Graph.visitableOnce: VisitableOnceGraph get() = VisitableOnceGraph(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/AbstractLexer.kt ================================================ package com.quarkdown.core.lexer /** * A [Lexer] that expects iterations through [source] content. * @param source the content to be tokenized */ abstract class AbstractLexer( override val source: CharSequence, ) : Lexer { /** * Index of the latest scanned character within [source]. */ abstract val currentIndex: Int /** * @param position range of the uncaptured group * @return a new token that represents the uncaptured content in order to fill the gaps, or `null` to not fill gaps */ abstract fun createFillToken(position: IntRange): Token? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/Lexer.kt ================================================ package com.quarkdown.core.lexer /** * A scanner that transforms raw string data into a list of token. * For instance, the Markdown code `Hello _Quarkdown_` is tokenized by its implementation into `Hello `, `_Quarkdown_`. */ interface Lexer { /** * The content to be tokenized. */ val source: CharSequence /** * Disassembles some raw string into smaller tokens. * @return a lazy sequence of tokens, produced on demand */ fun tokenize(): Sequence } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/Token.kt ================================================ package com.quarkdown.core.lexer import com.quarkdown.core.visitor.token.TokenVisitor /** * A wrapper of a [TokenData] that may be parsed in order to extract information. * A token can be parsed into a [com.quarkdown.core.ast.Node]. * @param data the wrapped token */ abstract class Token( val data: TokenData, ) { /** * Accepts a visitor. * @param T output type of the visitor * @return output of the visit */ abstract fun accept(visitor: TokenVisitor): T } /** * Lazily accepts a sequence of tokens to a shared visitor. * @param visitor the visitor to visit for each token. * @return the list of results from each visit */ fun Sequence.acceptAll(visitor: TokenVisitor): List = this.map { it.accept(visitor) }.toList() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/TokenCoordinates.kt ================================================ package com.quarkdown.core.lexer /** * The position of a token within the source code. * @param line line index, starting from 0 * @param column character index within [line], starting from 0 */ data class TokenCoordinates( val line: Int, val column: Int, ) /** * Converts a range of indexes within a string to its `(x, y)` coordinates. * @param source source to extract coordinates from * @return `(x, y)` coordinates */ fun IntRange.toCoordinates(source: CharSequence): TokenCoordinates { TODO() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/TokenData.kt ================================================ package com.quarkdown.core.lexer /** * Data of a single, usually small, substring of the source code that stores a chunk of information. * For instance, the Markdown code `Hello _Quarkdown_` contains the tokens `Hello `, `_`, `Quarkdown`, `_`. * @param text the substring extracted from the source code, also known as _lexeme_. * @param position location of the token within the source code * @param groups capture groups values for this token * @param namedGroups capture groups that hold a name. [groups] does not contain groups from [namedGroups] * @see Token */ data class TokenData( val text: String, val position: IntRange, val groups: Sequence = emptySequence(), val namedGroups: Map = emptyMap(), ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/BaseMarkdownBlockTokenRegexPatterns.kt ================================================ package com.quarkdown.core.lexer.patterns import com.quarkdown.core.lexer.patterns.PatternHelpers.customId import com.quarkdown.core.lexer.regex.RegexBuilder import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.BlockCodeToken import com.quarkdown.core.lexer.tokens.BlockQuoteToken import com.quarkdown.core.lexer.tokens.BlockTextToken import com.quarkdown.core.lexer.tokens.CommentToken import com.quarkdown.core.lexer.tokens.FencesCodeToken import com.quarkdown.core.lexer.tokens.FootnoteDefinitionToken import com.quarkdown.core.lexer.tokens.HeadingToken import com.quarkdown.core.lexer.tokens.HorizontalRuleToken import com.quarkdown.core.lexer.tokens.LinkDefinitionToken import com.quarkdown.core.lexer.tokens.ListItemToken import com.quarkdown.core.lexer.tokens.NewlineToken import com.quarkdown.core.lexer.tokens.OrderedListToken import com.quarkdown.core.lexer.tokens.ParagraphToken import com.quarkdown.core.lexer.tokens.SetextHeadingToken import com.quarkdown.core.lexer.tokens.TableToken import com.quarkdown.core.lexer.tokens.UnorderedListToken /** * Regex patterns for [com.quarkdown.core.flavor.base.BaseMarkdownFlavor] blocks. */ open class BaseMarkdownBlockTokenRegexPatterns { /** * The rules that defines when a text node must interrupt. * This might be overridden by subclasses to add new interruptions. * @param includeList whether the `list` reference should be filled * @param includeTable whether the `table` reference should be filled */ open fun interruptionRule( includeList: Boolean = true, includeTable: Boolean = true, ): Regex = RegexBuilder("hr|heading|blockquote|fences|list|table| +\\n") .withReference("hr", horizontalRule.regex) // Interrupts on horizontal rule .withReference("heading", " {0,3}#{1,6}(?:\\s|$)") .withReference("fences", "^ {0,3}((`{3,})|(~{3,}))[^\\n]*\\n") .withReference("blockquote", " {0,3}>") .apply { if (includeList) withReference("list", " {0,3}(?:[*+-]|1[.)]) ") if (includeTable) withReference("table", table.regex) }.buildRegex() /** * 4-spaces indented content. * @see BlockCodeToken */ val blockCode by lazy { TokenRegexPattern( name = "BlockCode", wrap = ::BlockCodeToken, regex = "^( {4}[^\\n]+(?:\\n(?: *(?:\\n|\$))*)?)+", ) } /** * `>`-beginning content. * @see BlockQuoteToken */ val blockQuote by lazy { TokenRegexPattern( name = "BlockQuote", wrap = ::BlockQuoteToken, regex = RegexBuilder("^( {0,3}> ?(paragraph|[^\\n]*)(?:\\n|$))+") .withReference("paragraph", paragraph.regex) .build(), ) } /** * Any previously unmatched content (should not happen). * @see BlockTextToken */ val blockText by lazy { TokenRegexPattern( name = "BlockText", wrap = ::BlockTextToken, regex = "^[^\\n]+", ) } /** * An ignored piece of content wrapped in `` (the amount of `-` can vary). * @see CommentToken */ val comment by lazy { TokenRegexPattern( name = "BlockComment", wrap = ::CommentToken, regex = PatternHelpers.COMMENT, ) } /** * Fenced content within triple backticks or tildes, with an optional language tag. * @see FencesCodeToken */ val fencesCode by lazy { TokenRegexPattern( name = "FencesCode", wrap = ::FencesCodeToken, regex = RegexBuilder( "^( {0,3})fencesstart[ \\t]*lang?[ \\t]*caption?[ \\t]*customid?$" + "(?s)(.+?)" + "fencesend[ \\t]*$", ).withReference("fencesstart", "(?[`~]){3,}") .withReference("fencesend", "\\k{3,}") .withReference("lang", "(?.+?)") .withReference("caption", "(?${PatternHelpers.DELIMITED_TITLE})") .withReference("customid", customId("fencescode")) .build(), groupNames = listOf("fenceschar", "fencescodelang", "fencescodecaption", "fencescodecustomid"), ) } /** * Content that begins by a variable amount of `#`s. * @see HeadingToken */ val heading by lazy { TokenRegexPattern( name = "Heading", wrap = ::HeadingToken, regex = RegexBuilder("^ {0,3}(#{1,6})(!?)(?=\\s|$)(.*?)customid?trailing(?:\\n+|$)") .withReference("customid", customId("heading")) .withReference("trailing", "\\s*#*") // Trailing #s are ignored .build(), groupNames = listOf("headingcustomid"), ) } /** * Three or more bullets in sequence. * @see HorizontalRuleToken */ val horizontalRule by lazy { TokenRegexPattern( name = "HorizontalRule", wrap = ::HorizontalRuleToken, regex = "^ {0,3}((?:-[\\t ]*){3,}|(?:_[ \\t]*){3,}|(?:\\*[ \\t]*){3,})(?:\\R+|$)", ) } /** * Pattern builder for link definitions and footnote definitions. * @param inBrackets pattern for the label contained within the brackets * @param interruption pattern for the interruption rule of the definition. * For instance, link definitions are one-liners, while footnote definitions can be multiline * and interrupted the same way as paragraphs. */ private fun definitionPattern( inBrackets: String, content: String, interruption: String, ): String = RegexBuilder("^ {0,3}\\[$inBrackets\\]: *(?:\\n *)?$content *$interruption") .withReference("label", "(?!\\s*\\])(?:\\\\.|[^\\[\\]\\\\])+") .build() /** * Creation of a referenceable link defined by label, url and optional title. * @see LinkDefinitionToken */ val linkDefinition by lazy { TokenRegexPattern( name = "LinkDefinition", wrap = ::LinkDefinitionToken, regex = definitionPattern( inBrackets = "(label)", content = RegexBuilder("([^<\\s][^\\s]*|<.*?>)(?:(?: +(?:\\n *)?| *\\n *)(title))?") .withReference( "title", "(?:\"(?:\\\\\"?|[^\"\\\\])*\"|'[^'\\n]*(?:\\n[^'\\n]+)*\\n?'|\\([^()]*\\))", ).build(), interruption = "(?:\\n+|$)", ), ) } /** * Creation of a referenceable footnote defined by label and content. * @see FootnoteDefinitionToken */ val footnoteDefinition by lazy { TokenRegexPattern( name = "FootnoteDefinition", wrap = ::FootnoteDefinitionToken, regex = definitionPattern( inBrackets = "\\^(label)", content = "", interruption = "(.+(?:\\n(?!${interruptionRule()})[^\\n]+)*)*", ), ) } /** * Item of a list. * @see ListItemToken */ val listItem by lazy { TokenRegexPattern( name = "ListItem", wrap = ::ListItemToken, regex = RegexBuilder( "^(( {0,3})(?:bullet))([ \\t]\\[[ xX]\\]|(?:))[ \\t](((.+(\\n(?!(\\s+\\n| {0,3}(bullet))))?)*(\\s*^\\3 {2,})*)*)", ).withReference("bullet", PatternHelpers.BULLET) .build(), ) } /** * A blank line. * @see NewlineToken */ val newline by lazy { TokenRegexPattern( name = "Newline", wrap = ::NewlineToken, regex = "^(?: *(?:\\n|$))+", ) } /** * A numbered list. * @see OrderedListToken */ val orderedList by lazy { TokenRegexPattern( name = "OrderedList", wrap = ::OrderedListToken, regex = listPattern( bulletInitialization = "\\d{1,9}(?[\\.)])", bulletContinuation = "\\d{1,9}\\k", ), ) } /** * Plain text content. * @see ParagraphToken */ val paragraph by lazy { TokenRegexPattern( name = "Paragraph", wrap = ::ParagraphToken, regex = RegexBuilder("([^\\n]+(?:\\n(?!interruption)[^\\n]+)*)") .withReference("interruption", interruptionRule().pattern) .build(), ) } /** * Text followed by a horizontal rule on a new line. * @see SetextHeadingToken */ val setextHeading by lazy { TokenRegexPattern( name = "SetextHeading", wrap = ::SetextHeadingToken, regex = RegexBuilder("^(?:(?:(?! {0,3}(?:bullet))(.+?)customid?\\R)+?)bar *(?:\\R+|$)") .withReference("bullet", PatternHelpers.BULLET) .withReference("bar", " {0,3}(=+|-+)") .withReference("customid", customId("setext")) .build(), groupNames = listOf("setextcustomid"), ) } /** * GFM table with a header row, a delimiter row and multiple cell rows. * @see TableToken */ val table by lazy { TokenRegexPattern( name = "Table", wrap = ::TableToken, regex = RegexBuilder( // Header "^ *([^\\n ].*)\\n" + // Align " {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)" + // Cells "(?:\\n((?:(?! *\\n|interruption).*(?:\\n|$))*)\\n*|$)", ).withReference("interruption", interruptionRule(includeTable = false).pattern) .withReference("|table", "") .build(), ) } /** * A non-numbered list defined by the same kind of bullets. * @see UnorderedListToken */ val unorderedList by lazy { TokenRegexPattern( name = "UnorderedList", wrap = ::UnorderedListToken, regex = listPattern( bulletInitialization = "(?[*+-])", bulletContinuation = "\\k", ), ) } /** * Generates a regex TokenRegexPattern that matches a whole list block. * @param bulletInitialization bullet TokenRegexPattern that begins the block * @param bulletContinuation bullet TokenRegexPattern that continues the block (all the items should ideally share the same bullet type) */ private fun listPattern( bulletInitialization: String, bulletContinuation: String, ): String { val initialization = "^(( {0,3}$bulletInitialization)[ \\t]((?!^(\\s*\\n){2})" val continuation = "(.+(\\n|\$)|\\n\\s*^( {2,}| {0,3}$bulletContinuation[ \\t]))" return RegexBuilder("$initialization$continuation(?!^(interruption)))*)+") .withReference("interruption", interruptionRule(includeList = false).pattern) .withReference("list", " {0,3}(?:[*+-]|\\d[.)]) ") .build() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/BaseMarkdownInlineTokenRegexPatterns.kt ================================================ package com.quarkdown.core.lexer.patterns import com.quarkdown.core.lexer.regex.RegexBuilder import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.CodeSpanToken import com.quarkdown.core.lexer.tokens.CommentToken import com.quarkdown.core.lexer.tokens.CriticalContentToken import com.quarkdown.core.lexer.tokens.DiamondAutolinkToken import com.quarkdown.core.lexer.tokens.EmphasisToken import com.quarkdown.core.lexer.tokens.EntityToken import com.quarkdown.core.lexer.tokens.EscapeToken import com.quarkdown.core.lexer.tokens.ImageToken import com.quarkdown.core.lexer.tokens.LineBreakToken import com.quarkdown.core.lexer.tokens.LinkToken import com.quarkdown.core.lexer.tokens.ReferenceFootnoteToken import com.quarkdown.core.lexer.tokens.ReferenceImageToken import com.quarkdown.core.lexer.tokens.ReferenceLinkToken import com.quarkdown.core.lexer.tokens.StrikethroughToken import com.quarkdown.core.lexer.tokens.StrongEmphasisToken import com.quarkdown.core.lexer.tokens.StrongToken import com.quarkdown.core.lexer.tokens.UrlAutolinkToken /** * Regex patterns for [com.quarkdown.core.flavor.base.BaseMarkdownFlavor] inlines. */ open class BaseMarkdownInlineTokenRegexPatterns { /** * A backslash followed by a punctuation character. * @see EscapeToken */ val escape by lazy { TokenRegexPattern( name = "InlineEscape", wrap = ::EscapeToken, regex = "\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])", ) } /** * A text entity: ` `, `&xFF`, ` `. * @see EntityToken */ val entity by lazy { TokenRegexPattern( name = "InlineEntity", wrap = ::EntityToken, regex = "&(#(\\d+)|#x([0-9A-Fa-f]+)|\\w+);?", ) } /** * Characters that require attention in the rendering stage. * @see CriticalContentToken */ val criticalContent by lazy { TokenRegexPattern( name = "InlineCriticalContent", wrap = ::CriticalContentToken, regex = "[&<>\"']", ) } /** * An inline fragment of code wrapped by sequences of backticks of the same length. * @see CodeSpanToken */ val codeSpan by lazy { TokenRegexPattern( name = "InlineCodeSpan", wrap = ::CodeSpanToken, regex = "(?`+)([^`]|[^`][\\s\\S]*?[^`])\\k(?!`)", ) } /** * A hard line break given by two or more spaces at the end of the line. * @see LineBreakToken */ val lineBreak by lazy { TokenRegexPattern( name = "InlineLineBreak", wrap = ::LineBreakToken, regex = "( {2,}|\\\\)\\R(?!\\s*$)", ) } /** * A link with its label in square brackets and its URL and optional title in parentheses, * without spaces in-between. * @see LinkToken */ val link by lazy { TokenRegexPattern( name = "InlineLink", wrap = ::LinkToken, regex = RegexBuilder("\\[(label)\\]\\(\\s*(href)(?:\\s+(title))?\\s*\\)") .withReference("label", LABEL_HELPER) .withReference("href", "<(?:\\\\.|[^\\n<>\\\\])+>|[^\\s\\x00-\\x1f]*") .withReference("title", PatternHelpers.DELIMITED_TITLE) .build(), ) } /** * A URL wrapped in angle brackets. * @see DiamondAutolinkToken */ val diamondAutolink by lazy { TokenRegexPattern( name = "InlineDiamondAutolink", wrap = ::DiamondAutolinkToken, regex = RegexBuilder("<(scheme:[^\\s\\x00-\\x1f<>]*|email)>") .withReference("scheme", "[a-zA-Z][a-zA-Z0-9+.-]{1,31}") .withReference( "email", "[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])", ).build(), ) } /** * A plain URL. * @see UrlAutolinkToken */ val urlAutolink by lazy { TokenRegexPattern( name = "InlineUrlAutolink", wrap = ::UrlAutolinkToken, regex = RegexBuilder("url|email") .withReference("url", "((?:ftp|https?):\\/\\/|www\\.)(?:[a-zA-Z0-9\\-]+\\.?)+[^\\s<]*") .withReference( "email", "[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])", ).build(), ) } /** * A link whose reference in brackets matches that of a link definition, * with an optional label in brackets at the beginning. * @see ReferenceLinkToken */ val referenceLink by lazy { TokenRegexPattern( name = "InlineReferenceLink", wrap = ::ReferenceLinkToken, regex = RegexBuilder("\\[(label)\\](?:\\[(ref)?\\])?") .withReference("label", LABEL_HELPER) .withReference("ref", BLOCK_LABEL_HELPER) .build(), ) } /** * A reference whose label in brackets matches that of a footnote definition. * It may also contain an optional all-in-one definition. * @see ReferenceLinkToken */ val referenceFootnote by lazy { TokenRegexPattern( name = "InlineReferenceFootnote", wrap = ::ReferenceFootnoteToken, regex = RegexBuilder("\\[\\^(label)definition\\]") .withReference("label", LABEL_HELPER) .withReference("definition", "(?::[ \\t]*([^\\[\\]\\\\`]+?))?") .build(), ) } /** * An image, same as a link preceded by a `!`. * As an extension, Quarkdown introduces an optional `(WxH)` or `(W) to be added after the `!` the end which specifies * the image size, where W and H can be integers or `_` (auto). * @see ImageToken * @see link */ val image by lazy { TokenRegexPattern( name = "InlineImage", wrap = ::ImageToken, regex = RegexBuilder("!(?:\\(imgsize\\))?linkcustomid?") .withReference("imgsize", "(?.+?)(?:sizedivider(?.+?))?") .withReference("sizedivider", IMAGE_SIZE_DIVIDER_HELPER) .withReference("link", link.regex) .withReference("customid", PatternHelpers.customId("img")) .build(), groupNames = listOf("imgwidth", "imgheight", "imgcustomid"), ) } /** * An image that references a link definition, same as a reference link preceded by a `!`. * @see ReferenceImageToken * @see referenceLink */ val referenceImage by lazy { TokenRegexPattern( name = "InlineReferenceImage", wrap = ::ReferenceImageToken, regex = RegexBuilder("!(?:\\(imgsize\\))?linkcustomid?") .withReference("imgsize", "(?.+?)(?:sizedivider(?.+?))?") .withReference("sizedivider", IMAGE_SIZE_DIVIDER_HELPER) .withReference("link", referenceLink.regex) .withReference("customid", PatternHelpers.customId("refimg")) .build(), groupNames = listOf("refimgwidth", "refimgheight", "refimgcustomid"), ) } /** * An ignored piece of content wrapped in `` (the amount of `-` can vary). * @see CommentToken */ val comment by lazy { TokenRegexPattern( name = "InlineComment", wrap = ::CommentToken, regex = PatternHelpers.COMMENT, ) } // https://spec.commonmark.org/0.31.2/#emphasis-and-strong-emphasis /** * Content wrapped in single asterisks, following CommonMark emphasis guidelines. * @see EmphasisToken */ val emphasisAsterisk by lazy { TokenRegexPattern( name = "InlineEmphasisAsterisk", wrap = ::EmphasisToken, regex = delimiteredPattern(startDelimiter = "\\*", endDelimiter = "\\*+", strict = false), ) } /** * Content wrapped in single underscored, following CommonMark emphasis guidelines. * @see EmphasisToken */ val emphasisUnderscore by lazy { TokenRegexPattern( name = "InlineEmphasisUnderscore", wrap = ::EmphasisToken, regex = delimiteredPattern(startDelimiter = "_", endDelimiter = "_+", strict = true), ) } /** * Content wrapped in double asterisks, following CommonMark emphasis guidelines. * @see StrongToken */ val strongAsterisk by lazy { TokenRegexPattern( name = "InlineStrongAsterisk", wrap = ::StrongToken, regex = delimiteredPattern(startDelimiter = "\\*{2}", endDelimiter = "\\*{2,}", strict = false), ) } /** * Content wrapped in double underscores, following CommonMark emphasis guidelines. * @see StrongToken */ val strongUnderscore by lazy { TokenRegexPattern( name = "InlineStrongUnderscore", wrap = ::StrongToken, regex = delimiteredPattern(startDelimiter = "_{2}", endDelimiter = "_{2,}", strict = true), ) } /** * Content wrapped in triple asterisks, following CommonMark emphasis guidelines. * @see StrongEmphasisToken */ val strongEmphasisAsterisk by lazy { TokenRegexPattern( name = "InlineStrongEmphasisAsterisk", wrap = ::StrongEmphasisToken, regex = delimiteredPattern(startDelimiter = "\\*{3}", endDelimiter = "\\*{3,}", strict = false), ) } /** * Content wrapped in triple underscores, following CommonMark emphasis guidelines. * @see StrongEmphasisToken */ val strongEmphasisUnderscore by lazy { TokenRegexPattern( name = "InlineStrongEmphasisUnderscore", wrap = ::StrongEmphasisToken, regex = delimiteredPattern(startDelimiter = "_{3}", endDelimiter = "_{3,}", strict = true), ) } /** * Content wrapped in double tildes, following CommonMark emphasis guidelines. * @see StrikethroughToken */ val strikethrough by lazy { TokenRegexPattern( name = "InlineStrikethrough", wrap = ::StrikethroughToken, regex = delimiteredPattern("~{2}", strict = false), ) } } private const val PUNCTUATION_HELPER = "\\p{P}\\p{S}" // [this is a label] private const val LABEL_HELPER = "(?:\\[(?:\\\\.|[^\\[\\]\\\\])*\\]|\\\\.|`[^`]*`|[^\\[\\]\\\\`])*?" private const val BLOCK_LABEL_HELPER = "(?!\\s*\\])(?:\\\\.|[^\\[\\]\\\\])+" // Width and height separator in images. private const val IMAGE_SIZE_DIVIDER_HELPER = "(?:[* \\t]|(? val result = FunctionCallWalkerParser(remaining, allowsBody = false).parse() WalkedToken( token = FunctionCallToken(data, isBlock = false, walkerResult = result), charsConsumed = result.endIndex, ) }, ) } /** * An isolated function call. * Function name prefixed by '.', followed by a sequence of arguments * and an optional body, indented by at least 2 spaces or 1 tab like a list item body. * This is a 'flag' pattern, meaning it does not capture any content, * but instead detects the beginning of a function call and delegates the scanning to [FunctionCallWalkerParser]. * * The walker determines whether the call is block-level by checking the remainder after parsing: * if non-whitespace content follows on the same line, the call is inline-level and the walker rejects * the match, allowing the paragraph pattern to capture the entire line instead. */ val blockFunctionCall by lazy { TokenRegexPattern( name = "FunctionCall", wrap = { error("Block function call tokens are constructed by the walker") }, regex = RegexBuilder("^ {0,3}(?:call))") .withReference("call", inlineFunctionCall.regex.dropLast(1)) .build(), walker = { data, remaining -> val result = FunctionCallWalkerParser(remaining, allowsBody = true).parse() // The function call is block-level only if it spans the entire header line. val isBlock = remaining.isBlankUntilEndOfLine(result.endIndex) if (!isBlock) return@TokenRegexPattern null WalkedToken( token = FunctionCallToken(data, isBlock = true, walkerResult = result), charsConsumed = result.endIndex, ) }, ) } /** * Block function call variant for use in expression evaluation. * Uses the same regex as [blockFunctionCall] (with `allowsBody = true`), * but always produces a token: expression evaluation does not distinguish between block and inline * function calls, so no rejection is needed. */ val expressionBlockFunctionCall by lazy { TokenRegexPattern( name = "FunctionCall", wrap = { error("Block function call tokens are constructed by the walker") }, regex = blockFunctionCall.regex, walker = { data, remaining -> val result = FunctionCallWalkerParser(remaining, allowsBody = true).parse() WalkedToken( token = FunctionCallToken(data, isBlock = true, walkerResult = result), charsConsumed = result.endIndex, ) }, ) } } /** * Accepted pattern before a function call. */ const val FUNCTION_CALL_PATTERN_BEFORE = "^|\\s|[^a-zA-Z0-9.\\\\]" ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/PatternHelpers.kt ================================================ package com.quarkdown.core.lexer.patterns /** * Helper patterns for regex lexers. */ internal object PatternHelpers { /** Bullet point for unordered and ordered lists. */ const val BULLET = "[*+-]|\\d{1,9}[\\.)]" /** * Title enclosed in delimiters. * - `"This is a title"` * - `'This is a title'` * - `(This is a title)` */ const val DELIMITED_TITLE = """"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)""" /** * Pattern of one-line fenced content between two dollar signs, * which is used for [com.quarkdown.core.lexer.tokens.OnelineMathToken] and [com.quarkdown.core.lexer.tokens.InlineMathToken] * The spacing between the dollar signs and the inner content must be of one unit. * * Inner dollar signs are included in the content as long as they are not adjacent to whitespace or non-word characters. */ const val ONELINE_MATH = // Starting delimiter. "\\$[ \\t]" + // Ungreedy content: stop at the first delimiter wrapped by whitespace. "((?:[^$\\n]|[^\\s]\\$|\\$[^\\s])+?)" + // Ending delimiter. "(?|[\\s\\S]*?-->)" /** * Custom ID: {#custom-id} * @param prefixName the prefix for the named capturing group, preceding `customid`. * This is needed in order to avoid name clashes when combining multiple patterns. */ fun customId(prefixName: String) = "(?:[ \\t]*\\{#(?<${prefixName}customid>[^}]+)})" } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/QuarkdownBlockTokenRegexPatterns.kt ================================================ package com.quarkdown.core.lexer.patterns import com.quarkdown.core.lexer.regex.RegexBuilder import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.MultilineMathToken import com.quarkdown.core.lexer.tokens.OnelineMathToken import com.quarkdown.core.lexer.tokens.PageBreakToken /** * Regex patterns for [com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor]. */ class QuarkdownBlockTokenRegexPatterns : BaseMarkdownBlockTokenRegexPatterns() { override fun interruptionRule( includeList: Boolean, includeTable: Boolean, ): Regex = RegexBuilder("mmath|" + super.interruptionRule(includeList, includeTable).pattern) .withReference("mmath", " {0,3}(?:\\\${3,})[^\\n]*\\n") .buildRegex() /** * Three or more `<` characters not followed by any other character. * Indicates a page break. */ val pageBreak by lazy { TokenRegexPattern( name = "PageBreak", wrap = ::PageBreakToken, regex = "^ {0,3}<{3,}(?=\\s*\$)", ) } /** * Fenced content within triple dollar signs. * @see MultilineMathToken */ val multilineMath by lazy { TokenRegexPattern( name = "MultilineMath", wrap = ::MultilineMathToken, regex = RegexBuilder("^ {0,3}header((.|\\s)+?)fencesend[ \\t]*$") .withReference("header", "fencesstart[ \\t]*customid?[ \\t]*$") .withReference("fencesstart", "\\\${3,}") .withReference("fencesend", "\\\${3,}") .withReference("customid", PatternHelpers.customId("multilinemath")) .build(), groupNames = listOf("multilinemathcustomid"), ) } /** * Fenced content within spaced dollar signs on the same line, * with optional custom ID for cross-referencing. * @see OnelineMathToken */ val onelineMath by lazy { TokenRegexPattern( name = "OnelineMath", wrap = ::OnelineMathToken, regex = RegexBuilder("^ {0,3}math[ \\t]*customid?[ \\t]*$") .withReference("math", PatternHelpers.ONELINE_MATH) .withReference("customid", PatternHelpers.customId("onelinemath")) .build(), groupNames = listOf("onelinemathcustomid"), ) } /** * An isolated function call. * Function name prefixed by '.', followed by a sequence of arguments wrapped in curly braces * and an optional body, indented by 4 spaces like a list item body. */ val functionCall by lazy { FunctionCallPatterns().blockFunctionCall } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/QuarkdownInlineTokenRegexPatterns.kt ================================================ package com.quarkdown.core.lexer.patterns import com.quarkdown.core.lexer.regex.RegexBuilder import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.InlineMathToken /** * Regex patterns for [com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor]. */ class QuarkdownInlineTokenRegexPatterns : BaseMarkdownInlineTokenRegexPatterns() { /** * Function name prefixed by '.', followed by a sequence of arguments wrapped in curly braces. */ val inlineFunctionCall by lazy { FunctionCallPatterns().inlineFunctionCall } /** * Fenced content within spaced dollar signs on the same line. * @see InlineMathToken */ val inlineMath by lazy { TokenRegexPattern( name = "InlineMath", wrap = ::InlineMathToken, regex = RegexBuilder("(?<=^|\\s|\\W)math(?=$|\\s|\\W)") .withReference("math", PatternHelpers.ONELINE_MATH) .build(), ) } /** * Patterns for sequences of characters that correspond to text symbols. */ val textReplacements: List = TextSymbolReplacement.entries.map { it.toTokenPattern() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/TextSymbolReplacement.kt ================================================ package com.quarkdown.core.lexer.patterns import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.TextSymbolToken /** * Patterns for sequences of characters that correspond to text symbols. * @param result symbol that the sequence is replaced with * @param regex regex pattern that matches the sequence to be replaced */ enum class TextSymbolReplacement( val result: Char, val regex: Regex, ) { /** * `(C)` -> `©` */ COPYRIGHT('©', "\\(C\\)".toRegex()), /** * `(R)` -> `®` */ REGISTERED('®', "\\(R\\)".toRegex()), /** * `(TM)` -> `™` */ TRADEMARK('™', "\\(TM\\)".toRegex()), /** * `--` -> `—` */ EM_DASH('—', "--".toRegex()), /** * `-` -> `–` * * It must be surrounded by a word character and a space on both sides. */ EN_DASH('–', "(?<=\\w\\s)-(?=\\s\\w)".toRegex()), /** * `...` -> `…` * * Must be either at the beginning or end of a word, not in-between. */ ELLIPSIS('…', "(\\.\\.\\.(?=\\s|\$))|((?<=\\s|^)\\.\\.\\.)".toRegex()), /** * `->` -> `→` */ SINGLE_RIGHT_ARROW('→', "->".toRegex()), /** * `<-` -> `←` */ SINGLE_LEFT_ARROW('←', "<-".toRegex()), /** * `=>` -> `⇒` */ DOUBLE_RIGHT_ARROW('⇒', "=>".toRegex()), /** * `<==` -> `⇐` */ DOUBLE_LEFT_ARROW('⇐', "<==".toRegex()), /** * `>=` -> `≥` */ GREATER_EQUAL('≥', ">=".toRegex()), /** * `<=` -> `≤` */ LESS_EQUAL('≤', "<=".toRegex()), /** * `!=` -> `≠` */ NOT_EQUAL('≠', "!=".toRegex()), /** * `+-` -> `±` */ PLUS_MINUS('±', "\\+-".toRegex()), /** * `'` -> `‘` * * Must not be preceded by a word and must be followed by a word character. */ TYPOGRAPHIC_LEFT_APOSTROPHE('‘', "(?<=\\s|^)'(?=\\w)".toRegex()), /** * `'` -> `’` * * Must not be preceded by a whitespace. */ TYPOGRAPHIC_RIGHT_APOSTROPHE('’', "(? `“` * * Must not be preceded by a word character and must be followed by a word character. */ TYPOGRAPHIC_LEFT_QUOTATION_MARK('“', "(? `”` * * Must not be preceded by a whitespace and not followed by a word character. */ TYPOGRAPHIC_RIGHT_QUOTATION_MARK('”', "(? TextSymbolToken(data, symbol = this) }, regex = regex.pattern, ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/RegexBuilder.kt ================================================ package com.quarkdown.core.lexer.regex import com.quarkdown.core.util.replace /** * A builder for [Regex] patterns. * @param baseRegex initial pattern */ class RegexBuilder( baseRegex: String, ) { private val pattern = StringBuilder(baseRegex) /** * Adds a reference to the pattern. All the occurrences will be replaced. * @param label text to be replaced * @param regex new pattern to replace the label with * @return this for concatenation */ fun withReference( label: String, regex: String, ) = apply { pattern.replace(label, regex) } /** * @return the raw pattern string */ fun build(): String = pattern.toString() /** * @return a new [Regex] with the given [options]. */ fun buildRegex(vararg options: RegexOption): Regex = build().toRegex(options.toSet()) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/RegexLexer.kt ================================================ package com.quarkdown.core.lexer.regex import com.github.h0tk3y.betterParse.parser.ParseException import com.quarkdown.core.lexer.AbstractLexer import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.regex.pattern.groupify import com.quarkdown.core.util.filterNotNullValues import com.quarkdown.core.util.normalizeLineSeparators /** * A [Lexer] that identifies tokens by matching [Regex] patterns. * * Tokenization uses an iterative `find` loop: each iteration calls [Regex.find] starting from * [currentIndex], so that walker-driven position advances are respected without restarting * the entire regex search. * * @param source the content to be tokenized * @param patterns the patterns to search for, in descending order of priority */ abstract class RegexLexer( source: CharSequence, protected val patterns: List, // Line separators are set to \n to ensure consistent results. ) : AbstractLexer(source.normalizeLineSeparators()) { override var currentIndex: Int = 0 protected set /** * Extracts tokens from a single [Regex] match result. * Uncaptured parts of the source string preceding the match are converted into fill tokens * via [createFillToken]. * * If a pattern's walker rejects the match by returning `null`, this method reverts the scan position * and returns an empty list, signaling the caller to retry with the [fallbackPatterns]. * * @param result result of the [Regex] match * @param activePatterns the patterns to check against the match result (must correspond to the regex * that produced [result], so that named group lookups succeed) * @return the tokens produced from this match (typically one content token, optionally preceded by a fill token), * or an empty list if the walker rejected the match */ private fun extractMatchingTokens( result: MatchResult, activePatterns: List = patterns, ): List { val tokens = mutableListOf() for (pattern in activePatterns) { val group = result.groups[pattern.name] ?: continue val range = group.range // Save current position in case the walker rejects the match. val savedIndex = currentIndex // Fill-tokens are substrings that were not captured by any pattern. // These uncaptured groups are scanned and converted to tokens. if (range.first > currentIndex) { createFillToken(position = currentIndex until range.first)?.let { tokens += it } } // End of the match. currentIndex = range.last + 1 // Groups with a name, defined by the pattern. val namedGroups = pattern.groupNames .asSequence() .map { it to result.groups[it] } .filterNotNullValues() .toMap() // Regular groups that are not named. // They don't contain values from namedGroups. val groups = result.groups .asSequence() .filterNotNull() // Named groups don't appear in regular groups. .filterNot { namedGroups.containsValue(it) } .map { it.value } .toMutableList() // namedGroups as Map. val namedGroupsValues = namedGroups .mapValues { (_, group) -> group.value } .toMutableMap() // The token data. val data = TokenData( text = group.value, position = range, groups = groups.asSequence(), namedGroups = namedGroupsValues, ) // In case the pattern requires additional information that can't be supplied by regex, // its walker is invoked to produce a fully typed token and advance the scan position. // In most cases, a pattern will not have a walker. // Currently, only function calls have walkers (see parser.walker.funcall), // as regex cannot handle balanced argument delimiters. // A ParseException from the walker's grammar is treated as a rejection (same as returning null). val walked = if (pattern.walker != null) { try { pattern.walker(data, source.substring(currentIndex)) } catch (_: ParseException) { null } } else { null } if (walked != null) { currentIndex += walked.charsConsumed tokens += walked.token } else if (pattern.walker != null) { // The walker rejected this match (e.g., a block function call pattern determined // the content is actually inline-level, or a wrapped function call is incomplete). // Revert position and signal rejection. currentIndex = savedIndex return emptyList() } else { // Lets the corresponding Token subclass wrap the data. tokens += pattern.wrap(data) } } return tokens } override fun tokenize(): Sequence = sequence { currentIndex = 0 val regex: Regex = patterns.groupify() // Append an empty line to the tokenized source to prevent issues with some expressions. val paddedSource = "$source\n" while (currentIndex < source.length) { val prevIndex = currentIndex val result = regex.find(paddedSource, currentIndex) ?: break var tokens = extractMatchingTokens(result) // If a walker rejected the match, retry with the fallback regex // (which excludes walker-based patterns), allowing other patterns // (e.g. paragraph) to match at the same position. if (tokens.isEmpty() && currentIndex == prevIndex) { val fallbackTokens = fallbackPatterns?.let { (fallback, fallbackPatterns) -> fallback.find(paddedSource, currentIndex)?.let { extractMatchingTokens(it, fallbackPatterns) } } if (!fallbackTokens.isNullOrEmpty()) { tokens = fallbackTokens } else if (currentIndex == prevIndex) { // Neither the main regex nor the fallback produced tokens at this position. // Emit a fill token for the rejected character and advance by one, // so the main regex can retry at the next position. // This handles e.g. a malformed wrapped function call `{.func {x}` // where the walker rejects the wrapped parse but the standard (non-wrapped) // match `.func {x}` should still be recognized at the next position. createFillToken(position = currentIndex..currentIndex)?.let { yield(it) } currentIndex++ continue } } yieldAll(tokens) // Safety: ensure forward progress to prevent infinite loops on zero-width matches. if (currentIndex <= prevIndex) break } // Add a token to fill the gap between the last token and the EOF. if (currentIndex < source.length) { createFillToken(position = currentIndex until source.length)?.let { yield(it) } } } /** * Pre-compiled fallback regex and its corresponding pattern list, excluding patterns with walkers. * Used when a walker rejects a match, allowing other patterns (e.g. paragraph) * to match at the same position where the walker-based pattern was rejected. * * `null` if no patterns have walkers (no fallback is ever needed). */ private val fallbackPatterns: Pair>? by lazy { val nonWalkerPatterns = patterns.filter { it.walker == null } if (nonWalkerPatterns.size == patterns.size) return@lazy null // No walkers exist. if (nonWalkerPatterns.isEmpty()) return@lazy null nonWalkerPatterns.groupify() to nonWalkerPatterns } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/StandardRegexLexer.kt ================================================ package com.quarkdown.core.lexer.regex import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern /** * A standard [RegexLexer] implementation that does not perform any manipulation on the final output and has an optional fixed fill token. * @param source the content to be tokenized * @param patterns the patterns to search for, in descending order of priority * @param fillTokenType type of token to assign to uncaptured groups in order to fill the gaps. A token will not be created if `null` and empty gaps will be present in the output */ class StandardRegexLexer( source: CharSequence, patterns: List, private val fillTokenType: ((TokenData) -> Token)? = null, ) : RegexLexer(source, patterns) { override fun createFillToken(position: IntRange): Token? { if (fillTokenType == null) { return null } val text = source.substring(position) return TokenData( text, position, ).let { fillTokenType.invoke(it) } } /** * Creates a copy of this lexer with different patterns. * @param newPatterns supplier of the new patterns, with the current ones as arguments * @return a new instance of [StandardRegexLexer]. */ fun updatePatterns(newPatterns: (List) -> (List)) = StandardRegexLexer( source, newPatterns(super.patterns), fillTokenType, ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/pattern/NamedRegexPattern.kt ================================================ package com.quarkdown.core.lexer.regex.pattern /** * A regex pattern with a name, that can be used with [groupify] to group multiple patterns into a bigger pattern. */ interface NamedRegexPattern { /** * Name of the pattern, used to identify the capture group. * Should not include special characters and must be unique. */ val name: String /** * The raw regex pattern string. */ val regex: String } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/pattern/RegexPatternGroup.kt ================================================ package com.quarkdown.core.lexer.regex.pattern import java.util.concurrent.ConcurrentHashMap /** * Cache for compiled [Regex] patterns, keyed by their joined string representation. * Avoids recompiling the same regex patterns on every lexer instantiation. */ private val groupifyCache = ConcurrentHashMap() /** * Groups a sequence of patterns into a single [Regex] where every capture group is identified by its token type (name). * The result is cached so that repeated calls with the same patterns reuse the compiled [Regex]. * @return a single [Regex] that captures all groups. */ fun Iterable.groupify(): Regex { val joined = joinToString(separator = "|") { pattern -> "(?<${pattern.name}>${pattern.regex})" } return groupifyCache.getOrPut(joined) { joined.toRegex(setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/pattern/TokenRegexPattern.kt ================================================ package com.quarkdown.core.lexer.regex.pattern import com.quarkdown.core.ast.Node import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData /** * A [Regex] pattern that captures a corresponding [Node] from a raw string. * @param name name of this pattern. * A name should not contain special characters (including underscores) * in order to prevent Regex compilation errors * @param wrap a function that wraps a general token into its specific wrapper * @param regex regex pattern to match * @param groupNames names of the named groups that appear the regex pattern * @param walker if present, upon being captured, receives the [TokenData] from the regex match and the remaining * source after the match, then produces a [WalkedToken] containing a fully typed token and the number * of additional characters consumed, or `null` to reject the match (e.g., when a block function call * determines it is actually inline-level content). * Used when regex alone cannot capture complex tokens (e.g., balanced delimiters in function call arguments) */ data class TokenRegexPattern( override val name: String, val wrap: (TokenData) -> Token, override val regex: String, val groupNames: List = emptyList(), val walker: ((TokenData, CharSequence) -> WalkedToken?)? = null, ) : NamedRegexPattern ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/regex/pattern/WalkedToken.kt ================================================ package com.quarkdown.core.lexer.regex.pattern import com.quarkdown.core.lexer.Token /** * Result of a walker invocation during tokenization. * Produced by patterns that require secondary scanning beyond what regex can capture * (e.g., balanced delimiters in function call arguments). * @param token the token produced by the walker, with walker data embedded * @param charsConsumed number of additional characters consumed beyond the regex match */ data class WalkedToken( val token: Token, val charsConsumed: Int, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/tokens/BlockTokens.kt ================================================ package com.quarkdown.core.lexer.tokens import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.visitor.token.TokenVisitor /** * A blank line. * @see com.quarkdown.core.ast.base.block.Newline */ class NewlineToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * Code * ``` * @see com.quarkdown.core.ast.base.block.Code */ class BlockCodeToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ~~~ * ```language * Code * ``` * ~~~ * * ``` * ~~~language * Code * ~~~ * ``` * @see com.quarkdown.core.ast.base.block.Code */ class FencesCodeToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A multiline fenced block that contains a TeX expression. * This is a custom Quarkdown block. * * Example: * $$$ * LaTeX expression line 1 * LaTeX expression line 2 * $$$ * @see com.quarkdown.core.ast.quarkdown.block.Math */ class MultilineMathToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * An isolated one-line fenced block that contains a TeX expression. * If it's not isolated, then it's an [InlineMathToken]. * This is a custom Quarkdown block. * * Example: * $ LaTeX expression $ * @see com.quarkdown.core.ast.quarkdown.block.Math */ class OnelineMathToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A thematic break. * Examples: * ``` * --- * ``` * ``` * ***** * ``` * @see com.quarkdown.core.ast.base.block.HorizontalRule */ class HorizontalRuleToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A page break. * This is a custom Quarkdown block. * * Example: * ``` * <<< * ``` * @see com.quarkdown.core.ast.quarkdown.block.PageBreak */ class PageBreakToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * # Heading * ``` * @see com.quarkdown.core.ast.base.block.Heading */ class HeadingToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * Heading * ==== * ``` * ``` * Heading * --- * ``` * @see com.quarkdown.core.ast.base.block.Heading */ class SetextHeadingToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * [label]: url "Title" * ``` * @see com.quarkdown.core.ast.base.block.LinkDefinition */ class LinkDefinitionToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * [^label]: Lorem ipsum * dolor sit * amet. * ``` * @see com.quarkdown.core.ast.base.block.FootnoteDefinition */ class FootnoteDefinitionToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * - A * - B * ``` * @see com.quarkdown.core.ast.base.block.list.UnorderedList */ class UnorderedListToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * 1. First * 2. Second * ``` * @see com.quarkdown.core.ast.base.block.list.OrderedList */ class OrderedListToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * - A * ``` * ``` * 1. First * ``` * @see com.quarkdown.core.ast.base.block.list.ListItem */ class ListItemToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * | foo | bar | * | --- | --- | * | baz | bim | * ``` * ``` * | foo | bar | * | :--- | ---: | * | baz | bim | * ``` * @see com.quarkdown.core.ast.base.block.Table */ class TableToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` *

* Content *

* ``` * @see com.quarkdown.core.ast.base.block.Html */ class HtmlToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * @see com.quarkdown.core.ast.base.block.Paragraph */ class ParagraphToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * > Quote * ``` * @see com.quarkdown.core.ast.base.block.BlockQuote */ class BlockQuoteToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * @see com.quarkdown.core.ast.base.block.BlankNode */ class BlockTextToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/tokens/FunctionCallToken.kt ================================================ package com.quarkdown.core.lexer.tokens import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.parser.walker.WalkerParsingResult import com.quarkdown.core.parser.walker.funcall.WalkedFunctionCall import com.quarkdown.core.visitor.token.TokenVisitor /** * A function call token, produced by the lexer's walker subsystem. * This is a custom Quarkdown element, and is both a block and inline node. * * Example: * ``` * .function {arg1} {arg2} * body * ``` * The `body` argument is supported only when used as a block. * @param isBlock whether the function call is a block (opposite: inline) * @param walkerResult the result of the walker parsing, containing the structured [WalkedFunctionCall] * @see com.quarkdown.core.ast.quarkdown.FunctionCallNode */ class FunctionCallToken( data: TokenData, val isBlock: Boolean, val walkerResult: WalkerParsingResult, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/tokens/InlineTokens.kt ================================================ package com.quarkdown.core.lexer.tokens import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.lexer.patterns.TextSymbolReplacement import com.quarkdown.core.visitor.token.TokenVisitor // Inline tokens /** * An escaped character. * Examples: `\#`, `\>`, ... */ class EscapeToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * An entity reference character. * Examples: ` `, `&`, `©`, '#', `"`, ... */ class EntityToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A character that requires special treatment during the rendering stage. * Examples: `&`, `<`, `>`, `"`, `'`, ... * @see com.quarkdown.core.ast.base.inline.CriticalContent */ class CriticalContentToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * `code` * ``` * ``` * ````code```` * ``` * @see com.quarkdown.core.ast.base.inline.CodeSpan */ class CodeSpanToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A soft line break. * Example: * ``` * Line 1 * Line 2 * ``` * @see com.quarkdown.core.ast.base.inline.LineBreak */ class LineBreakToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * [Quarkdown](https://github.com/iamgio/quarkdown) * ``` * @see com.quarkdown.core.ast.base.inline.Link */ class LinkToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * * ``` * @see com.quarkdown.core.ast.base.inline.Link */ class DiamondAutolinkToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * GFM extension. * Example: * ``` * https://github.com/iamgio/quarkdown * ``` * @see com.quarkdown.core.ast.base.inline.Link */ class UrlAutolinkToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * [text][label] * ``` * ``` * [label][] * ``` * ``` * [label] * ``` * @see com.quarkdown.core.ast.base.inline.ReferenceLink */ class ReferenceLinkToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * [^label] * ``` * ``` * [^label: definition] * ``` * ``` * [^: definition] * ``` * @see com.quarkdown.core.ast.base.inline.ReferenceFootnote */ class ReferenceFootnoteToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * ![Label](img.png) * ``` * @see com.quarkdown.core.ast.base.inline.Image */ class ImageToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * ![text][label] * ``` * ``` * ![label][] * ``` * ``` * ![label] * ``` * @see com.quarkdown.core.ast.base.inline.ReferenceImage */ class ReferenceImageToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * * ``` * @see com.quarkdown.core.ast.base.inline.Comment */ class CommentToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Text content. * @see com.quarkdown.core.ast.base.inline.Text */ class PlainTextToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * A sequence of characters that is replaced with a symbol (e.g. `(C)` -> ©). * This is a Quarkdown extension. * @param symbol symbol type * @see com.quarkdown.core.ast.quarkdown.inline.TextSymbol */ class TextSymbolToken( data: TokenData, val symbol: TextSymbolReplacement, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } // Emphasis /** * Examples: * ``` * **strong** * ``` * ``` * __strong__ * ``` * @see com.quarkdown.core.ast.base.inline.Strong */ class StrongToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * *emphasis* * ``` * ``` * _emphasis_ * ``` * @see com.quarkdown.core.ast.base.inline.Emphasis */ class EmphasisToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Examples: * ``` * ***emphasis*** * ``` * ``` * ___emphasis___ * ``` * @see com.quarkdown.core.ast.base.inline.StrongEmphasis */ class StrongEmphasisToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } /** * Example: * ``` * ~~text~~ * ``` * @see com.quarkdown.core.ast.base.inline.Strikethrough */ class StrikethroughToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } // Quarkdown extensions /** * A one-line fenced block that contains a TeX expression. * If it's isolated, then it's a [OnelineMathToken]. * This is a Quarkdown extension. * * Example: * $ LaTeX expression $ * @see com.quarkdown.core.ast.quarkdown.inline.MathSpan */ class InlineMathToken( data: TokenData, ) : Token(data) { override fun accept(visitor: TokenVisitor) = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/Locale.kt ================================================ package com.quarkdown.core.localization /** * Represents a locale, which defines the document language. */ interface Locale { /** * Language code of the locale. * For instance, `en` for English and `it` for Italian. */ val code: String /** * Country code of the locale. */ val countryCode: String? /** * Name of the locale, localized in English. * For instance, `English` for English and `Italian` for Italian. */ val displayName: String /** * Name of the locale, possibly in the locale's language itself. * For instance, `English` for English and `italiano` for Italian. */ val localizedName: String /** * Name of the country of the locale, possibly in the locale's language itself. * For instance, `United States` for `en-US` and `Italia` for `it`. */ val localizedCountryName: String? /** * Tag of the locale. * For instance, `en-US` for US English and `it` for Italian. */ val tag: String /** * Short tag of the locale. * For instance, `en` for English and `it` for Italian. */ val shortTag: String } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/LocaleLoader.kt ================================================ package com.quarkdown.core.localization import com.quarkdown.core.localization.jvm.JVMLocaleLoader /** * Loader of [Locale]s. */ interface LocaleLoader { /** * All available locales. */ val all: Iterable /** * @param tag language code of the locale and optionally the country code, separated by a hyphen. * Example: `en`, `en-US`, `it`, `fr-CA` * @return [Locale] with the given tag, or `null` if not found */ fun fromTag(tag: String): Locale? /** * @param name English name of the locale. * Example: `English`, `Italian`, `French` * @return [Locale] with the given name, or `null` if not found */ fun fromName(name: String): Locale? /** * Finds a locale by its tag or name. * @param identifier tag (`en`, `it`, `fr-CA`) or English name (`English`, `Italian`, `French (Canada)`) of the locale * @return [Locale] with the given tag or name, or `null` if not found */ fun find(identifier: String): Locale? = fromName(identifier) ?: fromTag(identifier) companion object { /** * Default system [LocaleLoader] implementation. */ val SYSTEM: LocaleLoader get() = JVMLocaleLoader } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/LocalizationExceptions.kt ================================================ package com.quarkdown.core.localization import com.quarkdown.core.RUNTIME_ERROR_EXIT_CODE import com.quarkdown.core.pipeline.error.PipelineException /** * An exception thrown when a localization-related error occurs. * @see com.quarkdown.core.context.Context.localize */ open class LocalizationException( message: String, ) : PipelineException(message, RUNTIME_ERROR_EXIT_CODE) /** * An exception thrown when a localization key is not found within a localization table. * @see LocalizationEntries */ class LocalizationKeyNotFoundException( tableName: String, locale: Locale, key: String, ) : LocalizationException( "Could not find localization key \"$key\" in table \"$tableName\" for locale ${locale.tag}", ) /** * An exception thrown when a locale is not found within a localization table. * @see LocalizationTable */ class LocalizationLocaleNotFoundException( tableName: String, locale: Locale, ) : LocalizationException( "Could not find locale ${locale.tag} in table \"$tableName\"", ) /** * An exception thrown when a localization table is not found. * @see LocalizationTables * @see LocalizationTable */ class LocalizationTableNotFoundException( tableName: String, ) : LocalizationException( "Could not find localization table \"$tableName\"", ) /** * An exception thrown when localization based on the currently set locale is being attempted, but the locale is not set. * @see com.quarkdown.core.document.DocumentInfo.locale */ class LocaleNotSetException : LocalizationException( "Trying to localize from a document that does not have a locale set. Tip: .doclang {locale}", ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/LocalizationTable.kt ================================================ package com.quarkdown.core.localization /** * A collection of multiple [LocalizationTable]s, each defined by its own unique name. * @see com.quarkdown.core.context.Context.localizationTables */ typealias LocalizationTables = Map /** * A mutable variant of [LocalizationTables]. */ typealias MutableLocalizationTables = MutableMap /** * A table that enables localization by storing localization entries for specific [Locale]s. * @see com.quarkdown.core.context.Context.localize */ typealias LocalizationTable = Map /** * Key-value pairs that define the localization associated with that key for a specific [Locale]. */ typealias LocalizationEntries = Map ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/jvm/JVMLocale.kt ================================================ package com.quarkdown.core.localization.jvm import com.quarkdown.core.localization.Locale /** * [Locale] implementation using [java.util.Locale]. */ internal data class JVMLocale( private val jvmLocale: JLocale, ) : Locale { override val code: String get() = jvmLocale.language override val countryCode: String? get() = jvmLocale.country.takeIf { it.isNotBlank() } override val displayName: String get() = jvmLocale.getDisplayName(JLocale.ENGLISH) override val localizedName: String get() = jvmLocale.getDisplayName(jvmLocale) override val localizedCountryName: String? get() = jvmLocale.getDisplayCountry(jvmLocale).takeIf { it.isNotBlank() } override val tag: String get() = jvmLocale.toLanguageTag() override val shortTag: String get() = jvmLocale.language } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/localization/jvm/JVMLocaleLoader.kt ================================================ package com.quarkdown.core.localization.jvm import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleLoader internal typealias JLocale = java.util.Locale /** * Loader of [JVMLocale]s. */ internal object JVMLocaleLoader : LocaleLoader { override val all: Iterable get() = JLocale.getAvailableLocales().map(::JVMLocale) private fun JLocale?.toLocale() = this ?.let(::JVMLocale) ?.takeIf { it.code.isNotBlank() } override fun fromTag(tag: String): Locale? = JLocale.forLanguageTag(tag)?.toLocale() override fun fromName(name: String): Locale? = JLocale .getAvailableLocales() .find { it.getDisplayName(JLocale.ENGLISH).equals(name, ignoreCase = true) } ?.toLocale() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/log/DebugFormatter.kt ================================================ package com.quarkdown.core.log import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.function.library.Library import com.quarkdown.core.lexer.Token /** * Utilities to log prettier debugging data. */ object DebugFormatter { private fun String.replaceEscapeCharacters() = replace("\\R".toRegex(), "\\\\n") .replace("\t", "\\t") .replace(" ", "\\t") /** * Pretty-formats a list of tokens. * @param tokens tokens to format * @return formatted string */ fun formatTokens(tokens: Iterable): String { val format = "%-25s %-20s %s" // Columns return tokens.joinToString(separator = "\n") { token -> val type = "type: ${token.javaClass.simpleName.removeSuffix("Token")}" val pos = "pos: ${token.data.position}" val content = token.data.text.replaceEscapeCharacters() val text = "text: $content" format.format(type, pos, text) } } /** * Pretty-formats an AST node. * @param node node to format * @return formatted string (without the 'children' attribute) */ private fun formatNode(node: Node): String = buildString { // Remove children from the node's toString() val text = node.toString() val index = text.indexOf("children=") append( if (node is NestableNode && index >= 0) { text.substring(0, index) } else { text }.replaceEscapeCharacters(), ) if (endsWith("(")) { setLength(length - 1) } if (endsWith(", ")) { setLength(length - 2) append(")") } } /** * Pretty-formats an AST. * @param root root node of the AST * @param initialIndent initial amount of indentation * @return formatted string */ fun formatAST( root: NestableNode, initialIndent: Int = 0, ): String = buildString { val indent = "\t" append(indent.repeat(initialIndent)) append(formatNode(root)) append("\n") root.children.forEach { if (it is NestableNode) { append(formatAST(it, initialIndent + 1)) } else { append(indent.repeat(initialIndent + 1)).append(formatNode(it)) append("\n") } } } /** * Pretty-formats a list of libraries. * @param libraries libraries to format * @return formatted string */ fun formatLibraries(libraries: Iterable): String = libraries.joinToString { "${it.name} (${it.functions.size} functions)" } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/log/Log.kt ================================================ package com.quarkdown.core.log import org.apache.logging.log4j.Level import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger import org.apache.logging.log4j.core.config.Configurator /** * Bridge for logging utilities. */ object Log { /** * The standard text logger. */ private val logger: Logger by lazy { LogManager.getLogger(this.javaClass.name) } /** * SUCCESS level between INFO and WARN */ private val SUCCESS: Level = Level.forName("SUCCESS", Level.INFO.intLevel() - 1) // Log4J wrapper functions /** * Whether the logger is at debugging level. */ private val isDebug: Boolean get() = logger.level == Level.DEBUG fun debug(message: Any) = logger.debug(message) /** * Logs the result of [message] lazily, only if the logger is set at debug level. */ fun debug(message: () -> Any) { if (isDebug) { logger.debug(message()) } } fun debug(throwable: Throwable) { if (isDebug) { throwable.printStackTrace() } } fun info(message: Any) = logger.info(message) fun success(message: Any) = logger.log(SUCCESS, message) fun warn(message: Any) = logger.warn(message) fun error(message: Any) = logger.error(message) /** * Disables all logging. */ fun disableLogging() { Configurator.setRootLevel(Level.OFF) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/LocalMedia.kt ================================================ package com.quarkdown.core.media import java.io.File /** * A media that lives on the local filesystem. * @param file the local file where the media is stored */ data class LocalMedia( val file: File, ) : Media { override fun accept(visitor: MediaVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/Media.kt ================================================ package com.quarkdown.core.media /** * Represents a resource that can be referenced in a Quarkdown document * and that may need to be downloaded or processed. * For example, when exporting a document to HTML, remote images are handled by the browser, * while local ones need to be copied to the output resources. */ sealed interface Media { fun accept(visitor: MediaVisitor): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/MediaVisitor.kt ================================================ package com.quarkdown.core.media /** * A visitor for [Media] implementations. * @param T return type of the visitor */ interface MediaVisitor { fun visit(media: LocalMedia): T fun visit(media: RemoteMedia): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/RemoteMedia.kt ================================================ package com.quarkdown.core.media import java.net.URL /** * A media stored remotely. * @param url the URL where the media is stored */ data class RemoteMedia( val url: URL, ) : Media { override fun accept(visitor: MediaVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/ResolvableMedia.kt ================================================ package com.quarkdown.core.media import com.quarkdown.core.util.IOUtils import com.quarkdown.core.util.toURLOrNull import java.io.File /** * A generic media that is yet to be resolved to a [Media] subclass. * @param path path to the media, either a file or a URL * @param workingDirectory directory to resolve the media from, in case the path is relative */ data class ResolvableMedia( private val path: String, private val workingDirectory: File? = null, ) : Media { /** * The resolved media as a [LocalMedia] or [RemoteMedia]. */ private val resolved: Media by lazy(::resolve) /** * @return [LocalMedia] if the path is a file, [RemoteMedia] if the path is a URL * @throws IllegalArgumentException if the path cannot be resolved or if it is a directory */ private fun resolve(): Media { // If the path is a URL, it is remote. path.toURLOrNull()?.let { return RemoteMedia(it) } val file = IOUtils.resolvePath(path, workingDirectory) if (!file.exists()) throw IllegalArgumentException("Media path cannot be resolved: $path") if (file.isDirectory) throw IllegalArgumentException("Media is a directory: $path") return LocalMedia(file) } // Delegate to the resolved media. override fun accept(visitor: MediaVisitor): T = resolved.accept(visitor) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/export/MediaOutputResourceConverter.kt ================================================ package com.quarkdown.core.media.export import com.quarkdown.core.media.LocalMedia import com.quarkdown.core.media.Media import com.quarkdown.core.media.MediaVisitor import com.quarkdown.core.media.RemoteMedia import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.BinaryOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource /** * A converter of a [Media] to an [OutputResource]. * @param name generated media name */ class MediaOutputResourceConverter( private val name: String, ) : MediaVisitor { override fun visit(media: LocalMedia) = BinaryOutputArtifact( name, media.file.readBytes().toList(), ArtifactType.AUTO, ) override fun visit(media: RemoteMedia) = BinaryOutputArtifact( name, media.url .openStream() .readBytes() .toList(), ArtifactType.AUTO, ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/MutableMediaStorage.kt ================================================ package com.quarkdown.core.media.storage import com.quarkdown.core.media.Media import com.quarkdown.core.media.ResolvableMedia import com.quarkdown.core.media.export.MediaOutputResourceConverter import com.quarkdown.core.media.storage.name.MediaNameProviderStrategy import com.quarkdown.core.media.storage.name.SanitizedMediaNameProvider import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.media.storage.options.MediaTypeEnabledChecker import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import java.io.File /** * The name of the media subdirectory in the output resources. */ const val MEDIA_SUBDIRECTORY_NAME = "media" /** * A media storage that can be modified with new entries. * @param options storage rules * @param nameProvider strategy used to generate media names. * The name of a media defines the file name in the output directory, * hence this is the resource the document should refer to (e.g. images). */ class MutableMediaStorage( options: MediaStorageOptions, private val nameProvider: MediaNameProviderStrategy = SanitizedMediaNameProvider(), ) : ReadOnlyMediaStorage { /** * All the stored entries. */ private val bindings = mutableMapOf() /** * Visitor that checks if a media type is enabled and should be stored. */ private val enabledChecker = MediaTypeEnabledChecker(options) override val name: String = MEDIA_SUBDIRECTORY_NAME override val all: Set get() = bindings.values.toSet() override fun resolve(path: String): StoredMedia? = bindings[path] override fun toResource(): OutputResource { val subResources = this.all .map { val converter = MediaOutputResourceConverter(it.name) it.media.accept(converter) }.toSet() return OutputResourceGroup(this.name, subResources) } /** * Binds a media to a path. * @return the [StoredMedia] associated with the path. If a media was already bound to the path, it is returned. Otherwise, the new [media] is returned. * It may also return `null` if the media is not accepted into the storage. */ private fun bind( path: String, media: Media, ): StoredMedia? { // Media is not stored if its type isn't enabled. if (!media.accept(enabledChecker)) return null val media = StoredMedia( name = media.accept(nameProvider), media = media, storage = this, ) return bindings.putIfAbsent(path, media) ?: media } /** * Registers a media by its path. * @param path path to the media, either a file or a URL * @param media media to register * @return the [StoredMedia] associated with the media. If a media was already bound to the path, it is returned. Otherwise, the new [media] is returned. */ fun register( path: String, media: Media, ): StoredMedia? = bind(path, media) /** * Registers a media by its path. The corresponding media is resolved lazily from the path. * @param path path to the media, either a file or a URL * @param workingDirectory directory to resolve the media from, in case the path is relative * @return the [StoredMedia] associated with the path. If a media was already bound to the path, it is returned. Otherwise, the new [media] is returned. * It may also return `null` if the media is not accepted into the storage. * @see ResolvableMedia */ fun register( path: String, workingDirectory: File?, ): StoredMedia? = register(path, ResolvableMedia(path, workingDirectory)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/ReadOnlyMediaStorage.kt ================================================ package com.quarkdown.core.media.storage import com.quarkdown.core.media.Media import com.quarkdown.core.pipeline.output.OutputResource /** * A storage of [Media] that can only be read. * The main purpose is exporting media to the output files. * For example, an image `![](img.png)` references a local image. * In this case, the HTML renderer would normally produce a `` tag, but the image would not be found in the output directory. * This means that the image must be copied to the output directory (not necessarily under the same name), * and the tag must reference the copied image path instead. * @see MutableMediaStorage */ interface ReadOnlyMediaStorage { /** * The name of the storage. * It also defines the name of the subdirectory in the output directory where media from this storage is saved. */ val name: String /** * All the stored entries. */ val all: Set /** * Whether this storage does not contain any media. */ val isEmpty: Boolean get() = all.isEmpty() /** * Resolves a media by its path. * @param path path of the media. Can be a file path or a URL * @return the matching media, if any is found */ fun resolve(path: String): StoredMedia? /** * Converts this storage to an [OutputResource]. * This is used to export all media to the output directory. * Ideally, this method returns an [com.quarkdown.core.pipeline.output.OutputResourceGroup] * which contains all media inside of it. * @return an exportable resource containing all media */ fun toResource(): OutputResource } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/StoredMedia.kt ================================================ package com.quarkdown.core.media.storage import com.quarkdown.core.media.Media import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter /** * A media file paired with a name, used in a [ReadOnlyMediaStorage]. * @param name symbolic name of the media. This is the name which elements in the document should refer to (e.g. a local image). * This is reflected to the output file name in the output directory when the media is exported * @param media the media * @param storage reference to the storage that contains this media. */ data class StoredMedia( val name: String, val media: Media, val storage: ReadOnlyMediaStorage, ) { /** * Retrieves the path a stored media, starting from the output directory. * @param separator the separator to use in the path * @return the path to the media in the output directory, inside the [storage] subdirectory */ fun path(separator: String): String = FileResourceExporter.NameProvider.stringToFileName(storage.name) + separator + name /** * Retrieves the path a stored media, starting from the output directory and using `/` as separator. * @see path */ val path: String get() = path("/") } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/name/MediaNameProviderStrategy.kt ================================================ package com.quarkdown.core.media.storage.name import com.quarkdown.core.media.MediaVisitor /** * */ interface MediaNameProviderStrategy : MediaVisitor ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/name/SanitizedMediaNameProvider.kt ================================================ package com.quarkdown.core.media.storage.name import com.quarkdown.core.media.LocalMedia import com.quarkdown.core.media.RemoteMedia import com.quarkdown.core.util.sanitizeFileName /** * A media name generator that sanitizes the file name and includes a unique identifier in it. * For example, "path/to/my file.jpg" is mapped to "my-file@HASH.jpg" */ class SanitizedMediaNameProvider : MediaNameProviderStrategy { private fun String.sanitize() = this.sanitizeFileName(replacement = "-") // Local media are given a unique identifier based on their file name and hash code. override fun visit(media: LocalMedia) = buildString { append(media.file.nameWithoutExtension) append("@") append(media.file.hashCode()) append(".") append(media.file.extension) }.sanitize() // URLs are already unique, and they don't need an additional identifier. override fun visit(media: RemoteMedia) = media.url.toExternalForm().sanitize() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/options/MediaStorageOptions.kt ================================================ package com.quarkdown.core.media.storage.options /** * Read-only options that affect the rules of a context's media storage. * @see com.quarkdown.core.context.MutableContextOptions for an implementation */ interface MediaStorageOptions { /** * Whether remote media associated to a URL should be stored locally. * If enabled, the media is downloaded, stored in the output directory * and the element that references the media is updated to reference the new local path. * If null, the preference is determined by the active renderer. */ val enableRemoteMediaStorage: Boolean? /** * Whether local media should be stored locally in the output directory. * If enabled, the media is copied to the output directory * and the element that references the media is updated to reference the new path. * If null, the preference is determined by the active renderer. */ val enableLocalMediaStorage: Boolean? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/options/MediaTypeEnabledChecker.kt ================================================ package com.quarkdown.core.media.storage.options import com.quarkdown.core.media.LocalMedia import com.quarkdown.core.media.MediaVisitor import com.quarkdown.core.media.RemoteMedia /** * Checks whether a media type is enabled in [options]. * @param options media storage rules */ class MediaTypeEnabledChecker( private val options: MediaStorageOptions, ) : MediaVisitor { override fun visit(media: LocalMedia) = options.enableLocalMediaStorage // Should not happen, but it's best to throw an error to avoid ambiguous behavior. ?: throw IllegalStateException("Media storage option enableLocalMediaStorage is not determined.") override fun visit(media: RemoteMedia) = options.enableRemoteMediaStorage // Should not happen, but it's best to throw an error to avoid ambiguous behavior. ?: throw IllegalStateException("Media storage option enableRemoteMediaStorage is not determined.") } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/media/storage/options/ReadOnlyMediaStorageOptions.kt ================================================ package com.quarkdown.core.media.storage.options /** * Read-only options that affect the rules of a media storage. * Used in [com.quarkdown.core.rendering.PostRenderer] to determine the preferred rules of a rendering strategy. */ data class ReadOnlyMediaStorageOptions( override val enableRemoteMediaStorage: Boolean? = null, override val enableLocalMediaStorage: Boolean? = null, ) : MediaStorageOptions ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/Color.kt ================================================ package com.quarkdown.core.misc.color import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor /** * A color with red, green, blue and alpha components. * @param red red component (0-255) * @param green green component (0-255) * @param blue blue component (0-255) * @param alpha alpha component (0.0-1.0) */ data class Color( val red: Int, val green: Int, val blue: Int, val alpha: Double = MAX_ALPHA, ) : RenderRepresentable { override fun accept(visitor: RenderRepresentableVisitor) = visitor.visit(this) /** * @see com.quarkdown.core.misc.color.decoder.decode */ companion object { /** * Maximum value for RGB components. */ const val MAX_RGB = 255 /** * Maximum value for alpha component. */ const val MAX_ALPHA = 1.0 } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/NamedColor.kt ================================================ package com.quarkdown.core.misc.color /** * The CSS3 color catalogue, associating a color with its name. * Original source: [aarongarciah/css-spec-colors](https://github.com/aarongarciah/css-spec-colors/blob/master/docs/colors.json) * @param color color value */ @Suppress("unused") enum class NamedColor( val color: Color, ) { /** * https://www.w3schools.com/colors/color_tryit.asp?color=AliceBlue */ ALICEBLUE(240, 248, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=AntiqueWhite */ ANTIQUEWHITE(250, 235, 215), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Aqua */ AQUA(0, 255, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Aquamarine */ AQUAMARINE(127, 255, 212), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Azure */ AZURE(240, 255, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Beige */ BEIGE(245, 245, 220), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Bisque */ BISQUE(255, 228, 196), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Black */ BLACK(0, 0, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=BlanchedAlmond */ BLANCHEDALMOND(255, 235, 205), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Blue */ BLUE(0, 0, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=BlueViolet */ BLUEVIOLET(138, 43, 226), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Brown */ BROWN(165, 42, 42), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Burlywood */ BURLYWOOD(222, 184, 135), /** * https://www.w3schools.com/colors/color_tryit.asp?color=CadetBlue */ CADETBLUE(95, 158, 160), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Chartreuse */ CHARTREUSE(127, 255, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Chocolate */ CHOCOLATE(210, 105, 30), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Coral */ CORAL(255, 127, 80), /** * https://www.w3schools.com/colors/color_tryit.asp?color=CornflowerBlue */ CORNFLOWERBLUE(100, 149, 237), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Cornsilk */ CORNSILK(255, 248, 220), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Crimson */ CRIMSON(220, 20, 60), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Cyan */ CYAN(0, 255, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkBlue */ DARKBLUE(0, 0, 139), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkCyan */ DARKCYAN(0, 139, 139), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkGoldenrod */ DARKGOLDENROD(184, 134, 11), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkGray */ DARKGRAY(169, 169, 169), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkGreen */ DARKGREEN(0, 100, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkGrey */ DARKGREY(169, 169, 169), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkKhaki */ DARKKHAKI(189, 183, 107), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkMagenta */ DARKMAGENTA(139, 0, 139), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkOliveGreen */ DARKOLIVEGREEN(85, 107, 47), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkOrange */ DARKORANGE(255, 140, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkOrchid */ DARKORCHID(153, 50, 204), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkRed */ DARKRED(139, 0, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkSalmon */ DARKSALMON(233, 150, 122), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkSeaGreen */ DARKSEAGREEN(143, 188, 143), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkSlateBlue */ DARKSLATEBLUE(72, 61, 139), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkSlateGray */ DARKSLATEGRAY(47, 79, 79), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkSlateGrey */ DARKSLATEGREY(47, 79, 79), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkTurquoise */ DARKTURQUOISE(0, 206, 209), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DarkViolet */ DARKVIOLET(148, 0, 211), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DeepPink */ DEEPPINK(255, 20, 147), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DeepSkyBlue */ DEEPSKYBLUE(0, 191, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DimGray */ DIMGRAY(105, 105, 105), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DimGrey */ DIMGREY(105, 105, 105), /** * https://www.w3schools.com/colors/color_tryit.asp?color=DodgerBlue */ DODGERBLUE(30, 144, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Firebrick */ FIREBRICK(178, 34, 34), /** * https://www.w3schools.com/colors/color_tryit.asp?color=FloralWhite */ FLORALWHITE(255, 250, 240), /** * https://www.w3schools.com/colors/color_tryit.asp?color=ForestGreen */ FORESTGREEN(34, 139, 34), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Fuchsia */ FUCHSIA(255, 0, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Gainsboro */ GAINSBORO(220, 220, 220), /** * https://www.w3schools.com/colors/color_tryit.asp?color=GhostWhite */ GHOSTWHITE(248, 248, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Gold */ GOLD(255, 215, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Goldenrod */ GOLDENROD(218, 165, 32), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Gray */ GRAY(128, 128, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Green */ GREEN(0, 128, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=GreenYellow */ GREENYELLOW(173, 255, 47), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Grey */ GREY(128, 128, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Honeydew */ HONEYDEW(240, 255, 240), /** * https://www.w3schools.com/colors/color_tryit.asp?color=HotPink */ HOTPINK(255, 105, 180), /** * https://www.w3schools.com/colors/color_tryit.asp?color=IndianRed */ INDIANRED(205, 92, 92), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Indigo */ INDIGO(75, 0, 130), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Ivory */ IVORY(255, 255, 240), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Khaki */ KHAKI(240, 230, 140), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Lavender */ LAVENDER(230, 230, 250), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LavenderBlush */ LAVENDERBLUSH(255, 240, 245), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Lawngreen */ LAWNGREEN(124, 252, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LemonChiffon */ LEMONCHIFFON(255, 250, 205), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightBlue */ LIGHTBLUE(173, 216, 230), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightCoral */ LIGHTCORAL(240, 128, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightCyan */ LIGHTCYAN(224, 255, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightGoldenrodYellow */ LIGHTGOLDENRODYELLOW(250, 250, 210), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightGray */ LIGHTGRAY(211, 211, 211), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightGreen */ LIGHTGREEN(144, 238, 144), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightGrey */ LIGHTGREY(211, 211, 211), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightPink */ LIGHTPINK(255, 182, 193), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSalmon */ LIGHTSALMON(255, 160, 122), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSeaGreen */ LIGHTSEAGREEN(32, 178, 170), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSkyBlue */ LIGHTSKYBLUE(135, 206, 250), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSlateGray */ LIGHTSLATEGRAY(119, 136, 153), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSlateGrey */ LIGHTSLATEGREY(119, 136, 153), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightSteelBlue */ LIGHTSTEELBLUE(176, 196, 222), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LightYellow */ LIGHTYELLOW(255, 255, 224), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Lime */ LIME(0, 255, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=LimeGreen */ LIMEGREEN(50, 205, 50), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Linen */ LINEN(250, 240, 230), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Magenta */ MAGENTA(255, 0, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Maroon */ MAROON(128, 0, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumAquamarine */ MEDIUMAQUAMARINE(102, 205, 170), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumBlue */ MEDIUMBLUE(0, 0, 205), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumOrchid */ MEDIUMORCHID(186, 85, 211), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumPurple */ MEDIUMPURPLE(147, 112, 219), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumSeaGreen */ MEDIUMSEAGREEN(60, 179, 113), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumSlateBlue */ MEDIUMSLATEBLUE(123, 104, 238), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumSpringGreen */ MEDIUMSPRINGGREEN(0, 250, 154), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumTurquoise */ MEDIUMTURQUOISE(72, 209, 204), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MediumVioletRed */ MEDIUMVIOLETRED(199, 21, 133), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MidnightBlue */ MIDNIGHTBLUE(25, 25, 112), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MintCream */ MINTCREAM(245, 255, 250), /** * https://www.w3schools.com/colors/color_tryit.asp?color=MistyRose */ MISTYROSE(255, 228, 225), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Moccasin */ MOCCASIN(255, 228, 181), /** * https://www.w3schools.com/colors/color_tryit.asp?color=NavajoWhite */ NAVAJOWHITE(255, 222, 173), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Navy */ NAVY(0, 0, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=OldLace */ OLDLACE(253, 245, 230), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Olive */ OLIVE(128, 128, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=OliveDrab */ OLIVEDRAB(107, 142, 35), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Orange */ ORANGE(255, 165, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=OrangeRed */ ORANGERED(255, 69, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Orchid */ ORCHID(218, 112, 214), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PaleGoldenrod */ PALEGOLDENROD(238, 232, 170), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PaleGreen */ PALEGREEN(152, 251, 152), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PaleTurquoise */ PALETURQUOISE(175, 238, 238), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PaleVioletRed */ PALEVIOLETRED(219, 112, 147), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PapayaWhip */ PAPAYAWHIP(255, 239, 213), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PeachPuff */ PEACHPUFF(255, 218, 185), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Peru */ PERU(205, 133, 63), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Pink */ PINK(255, 192, 203), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Plum */ PLUM(221, 160, 221), /** * https://www.w3schools.com/colors/color_tryit.asp?color=PowderBlue */ POWDERBLUE(176, 224, 230), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Purple */ PURPLE(128, 0, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=RebeccaPurple */ REBECCAPURPLE(102, 51, 153), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Red */ RED(255, 0, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=RosyBrown */ ROSYBROWN(188, 143, 143), /** * https://www.w3schools.com/colors/color_tryit.asp?color=RoyalBlue */ ROYALBLUE(65, 105, 225), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SaddleBrown */ SADDLEBROWN(139, 69, 19), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Salmon */ SALMON(250, 128, 114), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SandyBrown */ SANDYBROWN(244, 164, 96), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SeaGreen */ SEAGREEN(46, 139, 87), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Seashell */ SEASHELL(255, 245, 238), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Sienna */ SIENNA(160, 82, 45), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Silver */ SILVER(192, 192, 192), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SkyBlue */ SKYBLUE(135, 206, 235), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SlateBlue */ SLATEBLUE(106, 90, 205), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SlateGray */ SLATEGRAY(112, 128, 144), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SlateGrey */ SLATEGREY(112, 128, 144), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Snow */ SNOW(255, 250, 250), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SpringGreen */ SPRINGGREEN(0, 255, 127), /** * https://www.w3schools.com/colors/color_tryit.asp?color=SteelBlue */ STEELBLUE(70, 130, 180), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Tan */ TAN(210, 180, 140), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Teal */ TEAL(0, 128, 128), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Thistle */ THISTLE(216, 191, 216), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Tomato */ TOMATO(255, 99, 71), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Turquoise */ TURQUOISE(64, 224, 208), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Violet */ VIOLET(238, 130, 238), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Wheat */ WHEAT(245, 222, 179), /** * https://www.w3schools.com/colors/color_tryit.asp?color=White */ WHITE(255, 255, 255), /** * https://www.w3schools.com/colors/color_tryit.asp?color=WhiteSmoke */ WHITESMOKE(245, 245, 245), /** * https://www.w3schools.com/colors/color_tryit.asp?color=Yellow */ YELLOW(255, 255, 0), /** * https://www.w3schools.com/colors/color_tryit.asp?color=YellowGreen */ YELLOWGREEN(154, 205, 50), ; constructor(red: Int, green: Int, blue: Int) : this(Color(red, green, blue)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/ColorDecoder.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.quarkdown.core.misc.color.Color /** * A strategy to decode a [Color] from a raw string. */ interface ColorDecoder { /** * Decodes a [Color] from a raw string. * @param raw raw string * @return a [Color] from the raw string according this the specific strategy, or `null` if the conversion fails */ fun decode(raw: String): Color? } /** * Decodes a [Color] from a raw string using the first decoder that successfully decodes it. * @param raw raw string * @param decoders ordered list of decoders. Defaults to all decoders. * @return a successfully decoded [Color], or `null` if no decoder can decode it */ fun Color.Companion.decode( raw: String, vararg decoders: ColorDecoder = arrayOf( HexColorDecoder, RgbColorDecoder, RgbaColorDecoder, HsvHslColorDecoder, NamedColorDecoder, ), ): Color? = decoders.firstNotNullOfOrNull { it.decode(raw) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/ColorDecoderUtils.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.quarkdown.core.misc.color.Color /** * @param awtColor Java AWT color * @return a [Color] from a Java AWT color */ fun Color.Companion.from(awtColor: java.awt.Color): Color = Color( red = awtColor.red, green = awtColor.green, blue = awtColor.blue, alpha = awtColor.alpha.toDouble() / 255, ) /** * @param colormathColor Colormath color * @return a [Color] from a Colormath color */ fun Color.Companion.from(colormathColor: com.github.ajalt.colormath.Color): Color = with(colormathColor.toSRGB()) { Color( red = (r * MAX_RGB).toInt(), green = (g * MAX_RGB).toInt(), blue = (b * MAX_RGB).toInt(), ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/HexColorDecoder.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.github.ajalt.colormath.model.RGB import com.quarkdown.core.misc.color.Color /** * Decodes a [Color] from a hexadecimal string (e.g. `#FF0000`). */ object HexColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { if (raw.firstOrNull() != '#') return null // Converted by Colormath. return try { Color.from(RGB(raw)) } catch (e: IllegalArgumentException) { null } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/HsvHslColorDecoder.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.github.ajalt.colormath.model.HSL import com.github.ajalt.colormath.model.HSV import com.quarkdown.core.misc.color.Color /** * Maximum value for the hue component. */ private const val MAX_HUE = 360 /** * Maximum value for saturation and lightness/value components. */ private const val MAX_SVL = 100 /** * Decodes a [Color] from an HSV or HSL string (e.g. `hsv(208, 70, 66)` or `hsl(208, 54, 43)`). */ object HsvHslColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { if (!raw.startsWith("hsl(") && !raw.startsWith("hsv(")) return null // HSL and HSV have the same structure (a degree (0-360) and two percentages (0-100)). for (method in arrayOf('l', 'v')) { val match = Regex("hs$method\\((\\d{1,3}), ?(\\d{1,3}), ?(\\d{1,3})\\)").find(raw) ?: continue val (h, s, lv) = match.destructured.let { (h, s, lv) -> Triple( // Hue (0-360). // e.g. 520 % 360 = 160 h .toFloatOrNull() ?.rem(MAX_HUE), // Normalized saturation (0-1). // [0, 100] -> [0, 1] s .toFloatOrNull() ?.takeIf { it <= MAX_SVL } ?.div(MAX_SVL), // Normalized lightness/value (0-1). // [0, 100] -> [0, 1] lv .toFloatOrNull() ?.takeIf { it <= MAX_SVL } ?.div(MAX_SVL), ) } if (h == null || s == null || lv == null) continue // Colormath color to be converted. val color = when (method) { 'l' -> HSL(h, s, lv) 'v' -> HSV(h, s, lv) else -> return null // Impossible } return Color.from(color) } return null } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/NamedColorDecoder.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.quarkdown.core.misc.color.Color import com.quarkdown.core.misc.color.NamedColor /** * Decodes a [Color] from the name (case-insensitive) of a [NamedColor] (e.g. `red`, `GREEN`, `bLuE`, `aliceblue`). */ object NamedColorDecoder : ColorDecoder { override fun decode(raw: String): Color? = NamedColor.entries .find { it.name.equals(raw, ignoreCase = true) } ?.color } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/color/decoder/RgbaColorDecoder.kt ================================================ package com.quarkdown.core.misc.color.decoder import com.quarkdown.core.misc.color.Color /** * Given a regex match result, extracts RGB components from it. * @return a list of red, green and blue components. Any can be `null` if the component is invalid. */ private fun extractRGBComponents(match: MatchResult): List = match.destructured.toList().map { component -> component.toIntOrNull()?.takeIf { it <= Color.MAX_RGB } } /** * Decodes a [Color] from an RGB string (e.g. `rgb(255, 100, 25)`). */ object RgbColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { if (!raw.startsWith("rgb(")) return null val match = Regex("rgb\\((\\d{1,3}), ?(\\d{1,3}), ?(\\d{1,3})\\)").find(raw) ?: return null val (r, g, b) = extractRGBComponents(match) return if (r != null && g != null && b != null) { Color(r, g, b) } else { null } } } /** * Decodes a [Color] from an RGBA string (e.g. `rgba(255, 100, 25, 0.5)`). */ object RgbaColorDecoder : ColorDecoder { override fun decode(raw: String): Color? { if (!raw.startsWith("rgba(")) return null val match = Regex("rgba\\((\\d{1,3}), ?(\\d{1,3}), ?(\\d{1,3}), ?([0-9.]+)\\)").find(raw) ?: return null val (r, g, b) = extractRGBComponents(match) val a = match.destructured .component4() .toDoubleOrNull() ?.takeIf { it <= Color.MAX_ALPHA } return if (r != null && g != null && b != null && a != null) { Color(r, g, b, a) } else { null } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/font/FontFamily.kt ================================================ package com.quarkdown.core.misc.font import java.net.URLEncoder private const val GOOGLE_FONTS_URL = "https://fonts.googleapis.com/css2" /** * A font family, which can be loaded from different sources. */ sealed interface FontFamily { /** * Name, path or URL that the font family was loaded from. */ val path: String /** * Unique identifier for the font family, based on its path. * Multiple font families with the same path will have the same ID. * * In HTML rendering, this ID is assigned to `font-family`. */ val id: String get() = path.hashCode().toString() /** * A font family that is installed on the system. * @param name the name of the system font */ data class System( val name: String, ) : FontFamily { override val path: String get() = name } /** * A font family that is loaded from a media source, such as a file or URL. * @param media the media object representing the font */ data class Media( val media: com.quarkdown.core.media.Media, override val path: String, ) : FontFamily /** * A font family that is loaded from [Google Fonts](https://fonts.google.com). * @param name the name of the Google Font, case-sensitive */ data class GoogleFont( val name: String, ) : FontFamily { override val path: String get() = "$GOOGLE_FONTS_URL?family=${URLEncoder.encode(name, Charsets.UTF_8)}" override val id: String get() = name } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/font/resolver/FontFamilyResolver.kt ================================================ package com.quarkdown.core.misc.font.resolver import com.quarkdown.core.misc.font.FontFamily import java.io.File /** * Resolver of a [FontFamily] by its name or path, from system fonts or media. */ interface FontFamilyResolver { /** * Resolves a [FontFamily] by its name or path. * @param nameOrPath the name of the system font or the path/URL to the font file * @param workingDirectory the working directory to resolve relative paths * @return a new [FontFamily] if found */ fun resolve( nameOrPath: String, workingDirectory: File?, ): FontFamily? companion object { /** * Default [FontFamilyResolver] implementation. */ val SYSTEM: FontFamilyResolver = JVMFontFamilyResolver } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/misc/font/resolver/JVMFontFamilyResolver.kt ================================================ package com.quarkdown.core.misc.font.resolver import com.quarkdown.core.media.ResolvableMedia import com.quarkdown.core.misc.font.FontFamily import java.awt.GraphicsEnvironment import java.io.File private const val GOOGLE_FONTS_PREFIX = "GoogleFonts:" /** * JVM/AWT implementation of [FontFamilyResolver]. */ internal object JVMFontFamilyResolver : FontFamilyResolver { private fun isSystemFont(name: String) = name in GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames override fun resolve( nameOrPath: String, workingDirectory: File?, ): FontFamily? = when { nameOrPath.startsWith(GOOGLE_FONTS_PREFIX) -> { val fontName = nameOrPath.removePrefix(GOOGLE_FONTS_PREFIX) FontFamily.GoogleFont(fontName) } isSystemFont(nameOrPath) -> FontFamily.System(nameOrPath) else -> { val media = ResolvableMedia(nameOrPath, workingDirectory) FontFamily.Media(media, nameOrPath) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/BlockTokenParser.kt ================================================ package com.quarkdown.core.parser import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariant import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.TaskListItemVariant import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.quarkdown.block.ImageFigure import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.context.MutableContext import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.acceptAll import com.quarkdown.core.lexer.patterns.PatternHelpers import com.quarkdown.core.lexer.tokens.BlockCodeToken import com.quarkdown.core.lexer.tokens.BlockQuoteToken import com.quarkdown.core.lexer.tokens.BlockTextToken import com.quarkdown.core.lexer.tokens.FencesCodeToken import com.quarkdown.core.lexer.tokens.FootnoteDefinitionToken import com.quarkdown.core.lexer.tokens.FunctionCallToken import com.quarkdown.core.lexer.tokens.HeadingToken import com.quarkdown.core.lexer.tokens.HorizontalRuleToken import com.quarkdown.core.lexer.tokens.HtmlToken import com.quarkdown.core.lexer.tokens.LinkDefinitionToken import com.quarkdown.core.lexer.tokens.ListItemToken import com.quarkdown.core.lexer.tokens.MultilineMathToken import com.quarkdown.core.lexer.tokens.NewlineToken import com.quarkdown.core.lexer.tokens.OnelineMathToken import com.quarkdown.core.lexer.tokens.OrderedListToken import com.quarkdown.core.lexer.tokens.PageBreakToken import com.quarkdown.core.lexer.tokens.ParagraphToken import com.quarkdown.core.lexer.tokens.SetextHeadingToken import com.quarkdown.core.lexer.tokens.TableToken import com.quarkdown.core.lexer.tokens.UnorderedListToken import com.quarkdown.core.util.iterator import com.quarkdown.core.util.nextOrNull import com.quarkdown.core.util.removeOptionalPrefix import com.quarkdown.core.util.trimDelimiters import com.quarkdown.core.visitor.token.BlockTokenVisitor /** * The position of this character in the delimiter of a table header defines its column alignment. */ private const val TABLE_ALIGNMENT_CHAR = ':' /** * A parser for block tokens. * @param context additional data to fill during the parsing process */ class BlockTokenParser( private val context: MutableContext, ) : BlockTokenVisitor { /** * @return the parsed content of the tokenization from [this] lexer */ private fun Lexer.tokenizeAndParse(): List = this .tokenize() .acceptAll(context.flavor.parserFactory.newParser(context)) /** * @return [this] raw string tokenized and parsed into processed inline content, * based on this [flavor]'s specifics */ private fun String.toInline(): InlineContent = context.flavor.lexerFactory .newInlineLexer(this) .tokenizeAndParse() override fun visit(token: NewlineToken): Node = Newline override fun visit(token: BlockCodeToken): Node = Code( language = null, // Removes first indentation. content = token.data.text .replace("^ {1,4}".toRegex(RegexOption.MULTILINE), "") .trim(), ) override fun visit(token: FencesCodeToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val initialSpaces = groups.next().length // Amount of spaces before the fence. val language = token.data.namedGroups["fencescodelang"] val caption = token.data.namedGroups["fencescodecaption"]?.trim() val referenceId = token.data.namedGroups["fencescodecustomid"]?.trim() // Removes, at most, the initial spaces from each line (GFM #101). val content = groups .next() .lineSequence() .map { it.replace("^ {0,$initialSpaces}".toRegex(), "") } .joinToString(separator = "\n") .removePrefix("\n") .removeSuffix("\n") return Code( language = language?.takeIf { it.isNotBlank() }?.trim(), caption = caption?.trimDelimiters(), referenceId = referenceId, content = content, ) } override fun visit(token: MultilineMathToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val customId = token.data.namedGroups["multilinemathcustomid"]?.trim() return Math( expression = groups.next().trim(), referenceId = customId, ) } override fun visit(token: OnelineMathToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val customId = token.data.namedGroups["onelinemathcustomid"]?.trim() return Math( expression = groups.next().trim(), referenceId = customId, ) } override fun visit(token: HorizontalRuleToken): Node = HorizontalRule override fun visit(token: HeadingToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val depth = groups.next().length // Amount of # characters. // e.g. ###! Heading => the heading is decorative, meaning it's not part of the document structure. val isDecorative = groups.next() == "!" val text = groups.next().trim() val customId = token.data.namedGroups["headingcustomid"]?.trim() return Heading( depth, text.toInline(), customId = customId, canBreakPage = !isDecorative, canTrackLocation = !isDecorative, excludeFromTableOfContents = isDecorative, ) } override fun visit(token: SetextHeadingToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val text = groups.next().trim() val customId = token.data.namedGroups["setextcustomid"]?.trim() return Heading( text = text.toInline(), depth = when (groups.next().firstOrNull()) { '=' -> 1 '-' -> 2 else -> throw IllegalStateException("Invalid setext heading characters") // Should not happen }, customId = customId, ) } override fun visit(token: LinkDefinitionToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) return LinkDefinition( label = groups.next().trim().toInline(), url = groups.next().trim(), // Remove first and last character title = groups.nextOrNull()?.trimDelimiters()?.trim(), fileSystem = context.fileSystem, ) } override fun visit(token: FootnoteDefinitionToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) return FootnoteDefinition( label = groups.next().trim(), text = groups .next() .trim() .toInline(), ) } /** * Parses list items from a list [token]. * @param token list token to extract the items from */ private fun extractListItems(token: Token) = context.flavor.lexerFactory .newListLexer(source = token.data.text) .tokenizeAndParse() .dropLastWhile { it is Newline } // Remove trailing blank lines /** * Sets [list] as the owner of each of its [ListItem]s. * Ownership is used while rendering to determine whether a [ListItem] * is part of a loose or tight list. * @param list list to set ownership for */ private fun updateListItemsOwnership(list: ListBlock) { list.children .asSequence() .filterIsInstance() .forEach { it.owner = list } } override fun visit(token: UnorderedListToken): Node { val children = extractListItems(token) return UnorderedList( isLoose = children.any { it is Newline }, children, ).also(::updateListItemsOwnership) } override fun visit(token: OrderedListToken): Node { val children = extractListItems(token) val groups = token.data.groups.iterator(consumeAmount = 3) // e.g. "1." val marker = groups.next().trim() return OrderedList( startIndex = marker.dropLast(1).toIntOrNull() ?: 1, isLoose = children.any { it is Newline }, children, ).also(::updateListItemsOwnership) } /** * Like [String.trimIndent], but each line requires at least [minIndent] whitespaces trimmed. */ private fun trimMinIndent( lines: Sequence, minIndent: Int, ): String { // Gets the amount of indentation to trim from the content. var indent = minIndent for (char in lines.first()) { if (char.isWhitespace()) { indent++ } else { break } } // Removes indentation from each line. val trimmedContent = lines.joinToString(separator = "\n") { it.replaceFirst("^ {1,$indent}".toRegex(), "") } return trimmedContent } override fun visit(token: ListItemToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val marker = groups.next() // Bullet/number groups.next() // Consume val task = groups.next() // Optional GFM task val content = token.data.text .removePrefix(marker) .removePrefix(task) val lines = content.lineSequence() if (lines.none()) { return ListItem(children = emptyList()) } // Trims the content, removing common indentation. val trimmedContent = trimMinIndent(lines, minIndent = marker.trim().length) // Additional features of this list item. val variants = buildList { // GFM 5.3 task list item. if (task.isNotBlank()) { val isChecked = "[ ]" !in task add(TaskListItemVariant(isChecked)) } } // Parsed content. val children = context.flavor.lexerFactory .newBlockLexer(source = trimmedContent) .tokenizeAndParse() return ListItem(variants, children) } override fun visit(token: TableToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val columns = mutableListOf() val separator = "|" /** * Extracts the cells from a table row as raw strings. */ fun splitRow(row: String): Sequence = row .trim() .removePrefix(separator) .removeSuffix(separator) .split(Regex("(? = splitRow(row).map { Table.Cell(it.toInline()) } // Header row. parseRow(groups.next()).forEach { columns += Table.MutableColumn(Table.Alignment.NONE, it, mutableListOf()) } // Delimiter row (defines alignment). splitRow(groups.next()).forEachIndexed { index, delimiter -> columns.getOrNull(index)?.alignment = when { // :---: delimiter.firstOrNull() == TABLE_ALIGNMENT_CHAR && delimiter.lastOrNull() == TABLE_ALIGNMENT_CHAR -> Table.Alignment.CENTER // :--- delimiter.firstOrNull() == TABLE_ALIGNMENT_CHAR -> Table.Alignment.LEFT // ---: delimiter.lastOrNull() == TABLE_ALIGNMENT_CHAR -> Table.Alignment.RIGHT // --- else -> Table.Alignment.NONE } } // Quarkdown extension: a table may have metadata. // A caption is located at the end of the table, after a line break, wrapped by a delimiter, the same way as a link/image title. // "This is a caption", 'This is a caption', (This is a caption) // A custom ID, e.g. {#custom-id}, can be set for cross-referencing. val titlePattern = PatternHelpers.DELIMITED_TITLE val customIdPattern = PatternHelpers.customId("table") val metadataRegex = Regex("^[ \\t]*($titlePattern)?[ \\t]*$customIdPattern?[ \\t]*$") // The found caption and custom ID (reference ID) of the table, if any. var metadataFound = false var caption: String? = null var customId: String? = null // Other rows. groups .next() .lineSequence() .filterNot { it.isBlank() } .onEach { row -> // Extract the metadata if this is the metadata row. metadataRegex.find(row)?.let { metadataMatch -> metadataFound = true caption = metadataMatch.groupValues .getOrNull(1) ?.takeIf { it.isNotBlank() } ?.trimDelimiters() customId = metadataMatch.groupValues .getOrNull(2) ?.takeIf { it.isNotBlank() } ?.trim() } }.filterNot { metadataFound } // The metadata row is at the end of the table and not part of the table itself. .forEach { row -> var cellCount = 0 // Push cell. parseRow(row).forEachIndexed { index, cell -> columns.getOrNull(index)?.cells?.add(cell) cellCount = index } // Fill missing cells. for (remainingRow in cellCount + 1 until columns.size) { columns[remainingRow].cells += Table.Cell(emptyList()) } } return Table( columns = columns.map { it.toColumn() }, caption = caption, referenceId = customId, ) } override fun visit(token: HtmlToken): Node = Html( content = token.data.text.trim(), ) override fun visit(token: ParagraphToken): Node { val text = token.data.text .trim() .toInline() // If the paragraph only consists of a single child, it could be a special block. return when (val singleChild = text.singleOrNull()) { // Single image -> a figure. is Image -> ImageFigure(singleChild) // Regular paragraph otherwise (most cases). else -> Paragraph(text) } } override fun visit(token: BlockQuoteToken): Node { // Remove leading > var text = token.data.text .replace("^ *>[ \\t]?".toRegex(RegexOption.MULTILINE), "") .trim() // Blockquote type, if any. e.g. Tip, note, warning. val type: BlockQuote.Type? = BlockQuote.Type.entries.find { type -> sequenceOf( "${type.name}: ", // e.g. Tip:, Note:, Warning: "[!${type.name}]", // e.g. [!TIP], [!NOTE], [!WARNING] ).any { prefix -> val (newText, found) = text.removeOptionalPrefix(prefix, ignoreCase = true) if (found) text = newText.trimStart() found } } // Content nodes. var children = context.flavor.lexerFactory .newBlockLexer(source = text) .tokenizeAndParse() // If the last child is a single-item unordered list, then it's not part of the blockquote, // but rather its content is the attribution of the citation. // Example: // > To be, or not to be, that is the question. // > - William Shakespeare val attribution: InlineContent? = (children.lastOrNull() as? UnorderedList) ?.children ?.singleOrNull() ?.let { it as? ListItem } // Only lists with one item are considered. ?.children ?.firstOrNull() ?.let { it as? TextNode } // Usually a paragraph. ?.text // The text of the attribution, as inline content. ?.also { children = children.dropLast(1) } // If found, the attribution is not part of the children. return BlockQuote( type, attribution, children, ) } override fun visit(token: BlockTextToken): Node = BlankNode override fun visit(token: PageBreakToken): Node = PageBreak() override fun visit(token: FunctionCallToken): Node { val result = token.walkerResult val call = result.value // The range of the function call in the source code. // Note: the end index is provided by the walker, not the lexer. val sourceRangeStart = token.data.position.first val sourceRangeEnd = sourceRangeStart + result.endIndex val sourceRange = sourceRangeStart..sourceRangeEnd val sourceText = result.sourceText // The syntax-only information held by the walked function call is converted to a context-aware function call node. // Function chaining is also handled here, delegated to the refiner. val callNode = FunctionCallRefiner(context, call, token.isBlock, sourceText, sourceRange).toNode() // Enqueuing the function call, in order to expand it in the next stage of the pipeline. context.register(callNode) return callNode } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/FunctionCallRefiner.kt ================================================ package com.quarkdown.core.parser import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.context.Context import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.call.UncheckedFunctionCall import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.parser.walker.funcall.WalkedFunctionCall /** * Refines a [WalkedFunctionCall], which is a context-free syntactical element extracted from a function call in a source code, * into a processed, context-aware [FunctionCallNode]. * * This refiner also handles function chaining, which is represented by a linked list in the [WalkedFunctionCall] structure, * but is a tree in the [FunctionCallNode] structure. * * Example: * 1. Source code: `.foo {x}::bar {y}` * 2. Walked function call: `.foo {x}` -> `.bar {y}` * 3. Refinement: `.bar {.foo {x}} {y}` * * @param context context of the function call * @param call walked function call to refine * @param isBlock whether the function call is a block * @param sourceText if available, the source code of the whole function call * @param sourceRange if available, the range of the function call in the source code * @param initialArguments initial arguments to add to the function call (used internally for chaining) */ class FunctionCallRefiner( private val context: Context, private val call: WalkedFunctionCall, private val isBlock: Boolean, private val sourceText: CharSequence?, private val sourceRange: IntRange?, private val initialArguments: List = emptyList(), ) { /** * Extracts arguments from the walked function [call]. */ private fun extractArguments(): List { val arguments = initialArguments.toMutableList() // Inline function arguments. arguments += call.arguments .asSequence() .map { arg -> val raw = arg.value.trim() // Convert the raw argument to an expression. val expression = ValueFactory.safeExpression(raw, context) FunctionCallArgument( expression, name = arg.name, isBody = false, ) } // Body function argument. // A body argument is always the last one, it goes on a new line and each line is indented. call.bodyArgument?.takeUnless { it.value.isBlank() }?.value?.let { body -> // A body argument is treated as plain text, thus nested function calls are not executed by default. // They are executed if the argument is used as Markdown content from the referenced function, // that runs recursive lexing & parsing on the arg content, triggering function calls. val value = DynamicValue(body) arguments += FunctionCallArgument(value, isBody = true) } return arguments } /** * Refines the walked function [call] into a [FunctionCallNode]. */ fun toNode(): FunctionCallNode { val node = FunctionCallNode(context, call.name, extractArguments(), isBlock, sourceText, sourceRange) // Chaining: if this function call (A) is chained with another one (B), // then the result node is B(A). call.next?.let { next -> val call: UncheckedFunctionCall<*> = context.resolveUnchecked(node) // A val initialArguments = listOf(FunctionCallArgument(call)) // A as an argument for B val refiner = FunctionCallRefiner(context, next, isBlock, sourceText, sourceRange, initialArguments) // B(A) return refiner.toNode() } return node } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/InlineTokenParser.kt ================================================ package com.quarkdown.core.parser import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.LinkNode import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceDefinitionFootnote import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.isSubdocumentUrl import com.quarkdown.core.document.size.Size import com.quarkdown.core.flavor.InlineLexerVariant import com.quarkdown.core.function.value.factory.IllegalRawValueException import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.lexer.acceptAll import com.quarkdown.core.lexer.tokens.CodeSpanToken import com.quarkdown.core.lexer.tokens.CommentToken import com.quarkdown.core.lexer.tokens.CriticalContentToken import com.quarkdown.core.lexer.tokens.DiamondAutolinkToken import com.quarkdown.core.lexer.tokens.EmphasisToken import com.quarkdown.core.lexer.tokens.EntityToken import com.quarkdown.core.lexer.tokens.EscapeToken import com.quarkdown.core.lexer.tokens.ImageToken import com.quarkdown.core.lexer.tokens.InlineMathToken import com.quarkdown.core.lexer.tokens.LineBreakToken import com.quarkdown.core.lexer.tokens.LinkToken import com.quarkdown.core.lexer.tokens.PlainTextToken import com.quarkdown.core.lexer.tokens.ReferenceFootnoteToken import com.quarkdown.core.lexer.tokens.ReferenceImageToken import com.quarkdown.core.lexer.tokens.ReferenceLinkToken import com.quarkdown.core.lexer.tokens.StrikethroughToken import com.quarkdown.core.lexer.tokens.StrongEmphasisToken import com.quarkdown.core.lexer.tokens.StrongToken import com.quarkdown.core.lexer.tokens.TextSymbolToken import com.quarkdown.core.lexer.tokens.UrlAutolinkToken import com.quarkdown.core.misc.color.Color import com.quarkdown.core.misc.color.decoder.HexColorDecoder import com.quarkdown.core.misc.color.decoder.HsvHslColorDecoder import com.quarkdown.core.misc.color.decoder.RgbColorDecoder import com.quarkdown.core.misc.color.decoder.RgbaColorDecoder import com.quarkdown.core.misc.color.decoder.decode import com.quarkdown.core.util.Escape import com.quarkdown.core.util.iterator import com.quarkdown.core.util.nextOrNull import com.quarkdown.core.util.trimDelimiters import com.quarkdown.core.visitor.token.InlineTokenVisitor /** * ASCII of the character that replaces null characters, * following CommonMark's security guideline _(2.3 Insecure characters)_. */ private const val NULL_CHAR_REPLACEMENT_ASCII = 65533 /** * A parser for inline tokens. * @param context additional data to fill during the parsing process */ class InlineTokenParser( private val context: MutableContext, ) : InlineTokenVisitor { /** * @return the parsed content of the tokenization from [this] lexer */ private fun Lexer.tokenizeAndParse(): List = this .tokenize() .acceptAll(context.flavor.parserFactory.newParser(context)) /** * Tokenizes and parses sub-nodes. * @param source source to tokenize using the default inline lexer from this flavor * @return parsed nodes */ private fun parseSubContent(source: CharSequence) = context.flavor.lexerFactory .newInlineLexer(source) .tokenizeAndParse() /** * Tokenizes and parses sub-nodes within a link label. * @param source source to tokenize using the link label inline lexer from this flavor * @return parsed nodes */ private fun parseLinkLabelSubContent(source: CharSequence) = context.flavor.lexerFactory .newInlineLexer(source, variant = InlineLexerVariant.LINK_LABEL) .tokenizeAndParse() override fun visit(token: EscapeToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) return Text(text = groups.next()) } override fun visit(token: EntityToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val entity = groups.next().trim().lowercase() /** * @param radix radix to decode the numeric value for (`radix = 10` for decimal, `radix = 16` for hexadecimal) * @return [this] string to its corresponding character in [radix] representation. */ fun String.decodeToContent(radix: Int): String { val ascii = toIntOrNull(radix) ?: return "" // CommonMark's security guideline (2.3 Insecure characters) return if (ascii != 0) { ascii.toChar() } else { NULL_CHAR_REPLACEMENT_ASCII.toChar() }.toString() } // Critical because further checks and mappings may be required during the rendering stage. return CriticalContent( when { entity == "colon" -> ":" // Hexadecimal (e.g. ആ) entity.startsWith("#x") -> groups.next().decodeToContent(radix = 16) // Decimal (e.g. #) entity.startsWith("#") -> groups.next().decodeToContent(radix = 10) // HTML entity (e.g.  ) else -> Escape.Html.unescape(token.data.text) }, ) } override fun visit(token: CriticalContentToken): Node = CriticalContent(token.data.text) override fun visit(token: TextSymbolToken): Node { // The symbol is then treated separately from text in the renderer. // e.g. the HTML renderer converts the symbol to its corresponding HTML entity (© -> ©). return TextSymbol(token.symbol.result) } override fun visit(token: CommentToken): Node { // Content is ignored. return Comment } override fun visit(token: LineBreakToken): Node = LineBreak override fun visit(token: LinkToken): LinkNode { val groups = token.data.groups.iterator(consumeAmount = 2) val link = Link( label = parseLinkLabelSubContent(groups.next()), url = groups.next().trim(), // Removes leading and trailing delimiters. title = groups.nextOrNull()?.trimDelimiters()?.trim(), fileSystem = context.fileSystem, ) // The anchor is stripped from the URL, if present, to allow proper subdocument detection. // If the stripped URL points to a subdocument, it is a subdocument link. val result = link.stripAnchor() val strippedLink = result?.first ?: link val anchor = result?.second return when { context.isSubdocumentUrl(strippedLink.url) -> SubdocumentLink(strippedLink, anchor) else -> link } } override fun visit(token: ReferenceLinkToken): ReferenceLink { val groups = token.data.groups.iterator(consumeAmount = 2) val label = parseLinkLabelSubContent(groups.next()) // When the reference is collapsed, the label is the same as the reference label. return ReferenceLink( label = label, referenceLabel = groups.nextOrNull()?.let { parseLinkLabelSubContent(it) } ?: label, fallback = { Text(token.data.text) }, ) } override fun visit(token: ReferenceFootnoteToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val label = groups.next() val definition = groups.nextOrNull() return when { // All-in-one case: // Named: [^label: definition] // Anonymous: [^: definition] definition != null -> { ReferenceDefinitionFootnote( label.takeUnless { it.isBlank() } ?: context.newUuid(), definition = parseSubContent(definition), ) } // Reference only case. else -> { ReferenceFootnote( label, fallback = { Text(token.data.text) }, ) } } } override fun visit(token: DiamondAutolinkToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) val url = groups.next().trim() return visit(UrlAutolinkToken(token.data.copy(text = url))) } override fun visit(token: UrlAutolinkToken): Node { val url = token.data.text.trim() return Link( label = listOf(Text(url)), url = url, title = null, ) } /** * Given an image token, extracts its width and height, if they are set. * They are stored in the named groups `width` and `height`, both prefixed by [namedGroupPrefix]. * @param namedGroupPrefix prefix of the named groups * @param data token data to extract the size from * @return pair of width and height, or `null` if they are either unset or invalid */ private fun extractImageSize( namedGroupPrefix: String, data: TokenData, ): Pair { val width = data.namedGroups["${namedGroupPrefix}width"] val height = data.namedGroups["${namedGroupPrefix}height"] fun toSize(raw: String?): Size? = try { raw?.let(ValueFactory::size)?.unwrappedValue // Parses the value. } catch (_: IllegalRawValueException) { null } return toSize(width) to toSize(height) } override fun visit(token: ImageToken): Node { val link = visit(LinkToken(token.data)) val (width, height) = extractImageSize("img", token.data) val referenceId = token.data.namedGroups["imgcustomid"]?.trim() return Image(link, width, height, referenceId) } override fun visit(token: ReferenceImageToken): Node { val link = visit(ReferenceLinkToken(token.data)) val (width, height) = extractImageSize("refimg", token.data) val referenceId = token.data.namedGroups["refimgcustomid"]?.trim() return ReferenceImage(link, width, height, referenceId) } override fun visit(token: CodeSpanToken): Node { val groups = token.data.groups.iterator(consumeAmount = 3) val rawText = groups.next().replace("\n", " ") // If the text start and ends by a space, and does contain non-space characters, // the leading and trailing spaces are trimmed (according to CommonMark). val hasNonSpaceChars = rawText.any { it != ' ' } val hasSpaceCharsOnBothEnds = rawText.firstOrNull() == ' ' && rawText.lastOrNull() == ' ' // Trimmed final text. val text = if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { rawText.trimDelimiters() } else { rawText } // Additional content brought by the code span. // If null, no additional content is present. val content: CodeSpan.ContentInfo? = // Color decoding. Named colors are disabled due to performance reasons. Color .decode(text, HexColorDecoder, RgbColorDecoder, RgbaColorDecoder, HsvHslColorDecoder) ?.let(CodeSpan::ColorContent) return CodeSpan(text, content) } override fun visit(token: PlainTextToken): Node = Text(token.data.text) /** * @param token emphasis token to parse the content for * @return parsed content of an emphasis token */ private fun emphasisContent(token: Token): InlineContent { // The raw string content, without the delimiters. val text = token.data.groups .iterator(consumeAmount = 3) .next() return parseSubContent(text) } override fun visit(token: EmphasisToken): Node = Emphasis(emphasisContent(token)) override fun visit(token: StrongToken): Node = Strong(emphasisContent(token)) override fun visit(token: StrongEmphasisToken): Node = StrongEmphasis(emphasisContent(token)) override fun visit(token: StrikethroughToken): Node = Strikethrough(emphasisContent(token)) override fun visit(token: InlineMathToken): Node { val groups = token.data.groups.iterator(consumeAmount = 2) return MathSpan(expression = groups.next().trim()) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/GrammarUtils.kt ================================================ package com.quarkdown.core.parser.walker /** * Utilities for grammar parsing. */ object GrammarUtils { /** * Matches a character if it is not escaped. * @param string the string to match * @param position the position of the character to match * @param char the character to match * @param onMatch optional action to perform if the character is matched * @return 1 if the character is matched and not preceded by an escape character, 0 otherwise */ fun unescapedMatch( string: CharSequence, position: Int, char: Char, onMatch: () -> Unit = {}, ): Int = when { string[position] != char -> 0 string.getOrNull(position - 1) != '\\' -> { onMatch() 1 } else -> 0 } /** * Matches a balanced sequence delimited by [begin] and [end], ignoring escaped delimiters. * * Scans forward starting at [position] and returns the number of characters up to, * but not including, the balancing end delimiter when the delimiters are balanced. * Returns 0 if no balanced end is found. * * Rules: * - A delimiter preceded by a backslash (\) is ignored. * - Nested delimiters are supported and adjust the depth accordingly. * * @param string the source to scan * @param position the starting index to scan from * @param begin the opening delimiter * @param end the closing delimiter * @return the length from [position] to the matching end delimiter, or 0 if none */ fun balancedDelimitersMatch( string: CharSequence, position: Int, begin: Char, end: Char, ): Int { var depth = 0 for (x in position until string.length) { when { // Unescaped begin delimiter. unescapedMatch(string, x, begin) != 0 -> depth++ // Unescaped end delimiter. // This leads to the end of the argument if the delimiters are balanced. unescapedMatch(string, x, end) != 0 -> { if (depth == 0) { return x - position } depth-- } } } return 0 } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/WalkerParser.kt ================================================ package com.quarkdown.core.parser.walker import com.github.h0tk3y.betterParse.grammar.Grammar import com.github.h0tk3y.betterParse.parser.toParsedOrThrow /** * Lexer and parser that, thanks to `better-parse`'s context-free [Grammar], can parse a string into a structured object. * The term `Walker` refers to the fact this might be invoked by Quarkdown's lexer to walk through the source string, * in case content cannot be tokenized via regular expressions. * @param T the type of the parsed object * @param source the content to be parsed * @param grammar the grammar that defines the parsing rules * @see WalkerParsingResult */ open class WalkerParser( private val source: CharSequence, private val grammar: Grammar, ) { /** * Parses the [source] string into an output object according to the [grammar]-defined rules. * The parser interrupts when it reaches the end of the source string or when it encounters a syntax error. * @return the result of the parsing operation */ fun parse(): WalkerParsingResult { val tokens = grammar.tokenizer.tokenize(source.toString()) val result = grammar.tryParse(tokens, fromPosition = 0) val parsed = result.toParsedOrThrow() val endIndex = tokens[parsed.nextPosition]?.offset ?: source.length val sourceText = source.substring(0, endIndex) val remainder = source.substring(endIndex) return WalkerParsingResult(parsed.value, endIndex, tokens, sourceText, remainder) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/WalkerParsingResult.kt ================================================ package com.quarkdown.core.parser.walker import com.github.h0tk3y.betterParse.lexer.TokenMatchesSequence /** * The result of a [WalkerParser] parsing operation. * @param T the type of result, produced by the parser * @param value the result value, produced by the parser * @param endIndex the index, relative to the input string, at which the parsing operation ended * @param tokens the sequence of tokens that were matched during the tokenization by the walker * @param sourceText the original input string that was parsed * @param remainder the remaining content of the input string after the parsing operation * @see WalkerParser */ data class WalkerParsingResult( val value: T, val endIndex: Int, val tokens: TokenMatchesSequence, val sourceText: CharSequence, val remainder: CharSequence, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/funcall/FunctionCallGrammar.kt ================================================ package com.quarkdown.core.parser.walker.funcall import com.github.h0tk3y.betterParse.combinators.and import com.github.h0tk3y.betterParse.combinators.map import com.github.h0tk3y.betterParse.combinators.optional import com.github.h0tk3y.betterParse.combinators.or import com.github.h0tk3y.betterParse.combinators.unaryMinus import com.github.h0tk3y.betterParse.combinators.zeroOrMore import com.github.h0tk3y.betterParse.grammar.Grammar import com.github.h0tk3y.betterParse.lexer.literalToken import com.github.h0tk3y.betterParse.lexer.regexToken import com.github.h0tk3y.betterParse.lexer.token import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.parser.BlockTokenParser import com.quarkdown.core.parser.walker.GrammarUtils.balancedDelimitersMatch import com.quarkdown.core.parser.walker.GrammarUtils.unescapedMatch import com.quarkdown.core.parser.walker.funcall.FunctionCallGrammar.Companion.ARGUMENT_BEGIN import com.quarkdown.core.parser.walker.funcall.FunctionCallGrammar.Companion.ARGUMENT_END import com.quarkdown.core.parser.walker.funcall.FunctionCallGrammar.Companion.IDENTIFIER_PATTERN /** * Grammar that defines and parses a function call. * The output object is [WalkedFunctionCall], which will later be converted to a [FunctionCall] by [BlockTokenParser]. * * The following is an example of a function call: * ``` * .func {arg1} {arg2} name:{arg3} * body argument line 1 * body argument line 2 * ``` * * On its own, the function call grammar is context-aware, as the argument are not subject to syntactic rules, * as they may represent Markdown content (argument type checking is performed in the function expansion stage of the pipeline instead, see [com.quarkdown.core.function.call.binding]). * In order to achieve context-awareness, this grammar is stateful and mutable, as it needs to know whether it is currently parsing the outer 'rigid' syntax or an argument. * * @param allowsBody whether the function call allows an indented body argument. * Generally, this is true for block functions, false for inline functions. */ class FunctionCallGrammar( private val allowsBody: Boolean, ) : Grammar() { /** * Whether the parser has begun parsing the actual function call. */ private var began = false /** * Whether the parser is currently parsing an argument. * While parsing an argument, the parser should not perform syntactic checks as the argument may contain any content, including Markdown. * This is a mutable state variable that is set to true when an argument is being parsed and false when the argument ends. */ private var inArg = false companion object { /** * The character that prefixes a function call. */ const val BEGIN = '.' /** * The pattern for an identifier (function name or argument name). * An identifier may also be a number: see implicit lambda arguments for example. */ const val IDENTIFIER_PATTERN = "[a-zA-Z][a-zA-Z0-9]*|[0-9]+" /** * The character that begins an inline argument. */ const val ARGUMENT_BEGIN = '{' /** * The character that ends an inline argument. */ const val ARGUMENT_END = '}' /** * The character that delimits a named argument. */ const val NAMED_ARGUMENT_DELIMITER = ":" /** * The character that separates chained function calls. */ const val CHAIN_SEPARATOR = "::" } /** * Token that matches the content of an inline (= non-body) argument. * This token has the highest priority in the grammar: if [inArg] is `true`, * this is the only viable token to match and other syntax rules are ignored. */ private val argContent by token { string, position -> if (!inArg) return@token 0 val length = balancedDelimitersMatch( string, position, ARGUMENT_BEGIN, ARGUMENT_END, ) if (length > 0) { inArg = false } length } /** * Token that matches the beginning of a function call. */ private val begin by token { string, position -> unescapedMatch(string, position, BEGIN) { began = true } } /** * Token that matches whitespace, ignored between arguments */ private val whitespace by regexToken("[ \\t]+") /** * Token that matches the beginning of an inline argument. * Sets [inArg] to `true` if found. * @see ARGUMENT_BEGIN */ private val argumentBegin by token { string, position -> unescapedMatch(string, position, ARGUMENT_BEGIN) { if (!began) { began = true } else { inArg = true } } } /** * Token that matches the end of an inline argument. * Sets [inArg] to `false` if found. * @see ARGUMENT_END */ private val argumentEnd by token { string, position -> unescapedMatch(string, position, ARGUMENT_END) { inArg = false } } /** * Token that matches the separator between chained function calls. * e.g. `foo::bar`. */ private val chainSeparator by literalToken(CHAIN_SEPARATOR) /** * Token that matches the delimiter between an argument name and its value. * e.g. `name:{value}`. */ private val argumentNameDelimiter by literalToken(NAMED_ARGUMENT_DELIMITER) /** * Token that matches an identifier (function name or argument name). * @see IDENTIFIER_PATTERN */ private val identifier by token { string, position -> if (inArg) return@token 0 regexToken(IDENTIFIER_PATTERN).match(string, position) } /** * Token that matches the content of a body argument. * A body argument is not wrapped in braces and must be consistently indented with at least two spaces or one tab. */ private val bodyArgContent by token { string, position -> if (!allowsBody || inArg) return@token 0 // Length of the body argument. var length = 0 // Whether at least one indented line has been found. var found = false for (line in string.substring(position).lineSequence()) { val hasIndent = line.startsWith(" ") || line.startsWith("\t") // Blank lines (even if not indented) are included in the body argument. // In order to be matched, however, the body argument must contain at least one non-blank indented line. if (line.isNotBlank()) { when { hasIndent -> found = true else -> break } } length += line.length if (string.getOrNull(length + position) == '\n') length++ // Include line break in the character count. } when { found -> length else -> 0 } } /** * Parses an inline argument. * An inline argument is wrapped in braces and may contain any kind of content. * @see argContent */ private val argumentParser = ( -optional(whitespace) and // Optional named argument. optional(identifier and -argumentNameDelimiter) and argumentBegin and // Argument content. optional(argContent) and argumentEnd ) map { (name, begin, value, end) -> WalkedFunctionArgument( name = name?.text, value = value?.text?.trimIndent()?.trim() ?: "", range = begin.offset until end.offset + end.length, ) } /** * Parses a body argument. * @see bodyArgContent */ private val bodyArgumentParser = bodyArgContent map { value -> value.text.takeUnless { it.isBlank() }?.let { WalkedFunctionArgument( name = null, value = it.trimIndent().trimEnd(), range = value.offset until value.offset + value.length, ) } } /** * Parses a single function call. * A function call consists of a function name, inline arguments and an optional body argument. */ private val callParser = ( // Function name. identifier and // Inline arguments. zeroOrMore(argumentParser) and // Body argument. optional(-optional(whitespace) and bodyArgumentParser) ) map { (id, args, body) -> WalkedFunctionCall( id.text, args, body, ) } /** * Parses a chain of function calls, separated by [chainSeparator]. * The result is an ordered linked list of [WalkedFunctionCall]s, and the first of them is returned. */ private val chainCallParser = callParser and zeroOrMore(-chainSeparator and callParser) map { (first, rest) -> var current = first for (next in rest) { current.next = next current = next } first } /** * Entry point of the grammar. * Parses the whole chain of function calls. */ override val rootParser = (-begin and chainCallParser) or (-argumentBegin and -begin and chainCallParser and -argumentEnd) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/funcall/FunctionCallWalkerParser.kt ================================================ package com.quarkdown.core.parser.walker.funcall import com.quarkdown.core.parser.walker.WalkerParser /** * Parser that walks through a function call and produces a [WalkedFunctionCall]. * @param allowsBody whether the function call allows an indented body argument * @see FunctionCallGrammar * @see WalkedFunctionCall * @see WalkerParser */ class FunctionCallWalkerParser( source: CharSequence, allowsBody: Boolean, ) : WalkerParser( source, FunctionCallGrammar(allowsBody), ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/funcall/WalkedFunctionCall.kt ================================================ package com.quarkdown.core.parser.walker.funcall import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.parser.BlockTokenParser /** * Structured data produced by [FunctionCallWalkerParser] which represents a function call. * This syntax-only information will later be converted to a [FunctionCall] by [BlockTokenParser] * by injecting further context-aware information. * @param name the name of the function * @param arguments the function's arguments * @param bodyArgument the function's body argument, if any * @param next the next function call in the chain, if any */ data class WalkedFunctionCall( val name: String, val arguments: List, val bodyArgument: WalkedFunctionArgument?, var next: WalkedFunctionCall? = null, ) /** * Structured data produced by [FunctionCallWalkerParser] which represents a function call argument. * @param name the name of the argument, if the argument is named * @param value the raw value of the argument * @param range the range of the argument value (including delimiters) in the source text */ data class WalkedFunctionArgument( val name: String?, val value: String, val range: IntRange, ) /** * @return the last function call in the chain of this [WalkedFunctionCall]. * If this call is not chained, it returns itself. */ val WalkedFunctionCall.lastChainedCall: WalkedFunctionCall get() = generateSequence(this) { it.next }.last() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/Pipeline.kt ================================================ package com.quarkdown.core.pipeline import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.function.library.Library import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.pipeline.stage.execute import com.quarkdown.core.rendering.RenderingComponents /** * A representation of the sequential set of actions to perform in order to produce an output artifact from a raw source. * Each component of the pipeline takes an input from the output of the previous one. * @param context initial context data shared across this pipeline, which will is filled with useful information * that are handed over to other stages of this pipeline. * This allows gathering information on-the-fly without additional visits of the whole tree * @param libraries libraries to load and look up functions from * @param renderer supplier of the renderer implementation to use, produced by the flavor's [RendererFactory] * with the output attributes of the parser as an argument * @param hooks optional actions to run after each stage has been completed * @see PipelineChainFactory for standard pipeline stage chains */ class Pipeline( private val context: MutableContext, val options: PipelineOptions, val libraries: Set, private val renderer: (RendererFactory, Context) -> RenderingComponents, val hooks: PipelineHooks? = null, ) { private val renderingComponents: RenderingComponents by lazy { renderer(context.flavor.rendererFactory, context) } /** * A read-only version of the context of this pipeline. */ val readOnlyContext: Context get() = context fun copy(context: MutableContext = this.context): Pipeline = Pipeline( context = context, options = options, libraries = libraries, renderer = renderer, hooks = hooks, ) /** * Executes the pipeline and calls the given [hooks] after each stage. * @param source the source code to process and execute the stages onto * @throws PipelineException if an uncaught error occurs * @return a set of output resources generated by the pipeline */ fun executeUnwrapped(source: CharSequence): Set { val chain = PipelineChainFactory.fullChain(source, this.renderingComponents, this.options) val sharedData = SharedPipelineData( pipeline = this, context = context, ) return chain.execute(sharedData) } /** * Executes the pipeline and calls the given [hooks] after each stage. * @param source the source code to process and execute the stages onto * @throws PipelineException if an uncaught error occurs * @return a single output resource that wraps all the resources generated by the pipeline. */ fun execute(source: CharSequence): OutputResource? { val resources = executeUnwrapped(source) // The output name of the final wrapped resource. val outputName = options.resourceName ?: context.documentInfo.name ?: "Untitled Quarkdown Document" return renderingComponents.postRenderer.wrapResources(outputName, resources) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/PipelineChainFactory.kt ================================================ package com.quarkdown.core.pipeline import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.then import com.quarkdown.core.pipeline.stage.thenOptionally import com.quarkdown.core.pipeline.stages.AfterAllRenderingPeek import com.quarkdown.core.pipeline.stages.AttachmentStage import com.quarkdown.core.pipeline.stages.AttributesUpdateStage import com.quarkdown.core.pipeline.stages.FunctionCallExpansionStage import com.quarkdown.core.pipeline.stages.LexingStage import com.quarkdown.core.pipeline.stages.LibrariesRegistrationStage import com.quarkdown.core.pipeline.stages.ParsingStage import com.quarkdown.core.pipeline.stages.PostRenderingStage import com.quarkdown.core.pipeline.stages.RenderingStage import com.quarkdown.core.pipeline.stages.ResourceGenerationStage import com.quarkdown.core.pipeline.stages.TreeTraversalStage import com.quarkdown.core.rendering.RenderingComponents /** * Factory for creating standard pipeline stage chains. */ object PipelineChainFactory { /** * Creates a full pipeline stage chain that processes the input source text * through all stages, up to resource generation. * * @param source the raw input text to be processed * @param renderingComponents the rendering components to use in the rendering stages * @return a pipeline stage that processes the input source text and produces output resources */ fun fullChain( source: CharSequence, renderingComponents: RenderingComponents, options: PipelineOptions, ): PipelineStage> = AttachmentStage then LibrariesRegistrationStage then LexingStage(source) then ParsingStage then AttributesUpdateStage(preferredMediaStorageOptions = renderingComponents.postRenderer.preferredMediaStorageOptions) then FunctionCallExpansionStage then TreeTraversalStage then RenderingStage(renderingComponents.nodeRenderer) thenOptionally PostRenderingStage(renderingComponents.postRenderer).takeIf { options.wrapOutput } then AfterAllRenderingPeek then ResourceGenerationStage(renderingComponents.postRenderer) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/PipelineHooks.kt ================================================ package com.quarkdown.core.pipeline import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.function.library.Library import com.quarkdown.core.lexer.Token import com.quarkdown.core.pipeline.output.OutputResource /** * Actions to run after each stage of a [Pipeline] has been completed. * @param afterRegisteringLibraries action to run after the libraries have been registered and are ready to be looked up (libraries as arguments) * @param afterLexing action to run after the tokens have been produced (output tokens as arguments) * @param afterParsing action to run after the AST has been generated (root as an argument) * @param afterExpanding action to run after the queued function calls have been expanded (root as an argument) * @param afterTreeTraversal action to run after the produced AST has been visited * @param afterRendering action to run after the rendered output code has been generated (output code as an argument) * @param afterPostRendering action to run after the rendered output code has been manipulated (e.g. wrapped) (output code as an argument) * @param afterAllRendering action to run after all rendering has been completed. * It usually matches with [afterPostRendering]. * If post-rendering is disabled, it will match with [afterRendering] instead. * @see Pipeline */ data class PipelineHooks( val afterRegisteringLibraries: Pipeline.(Set) -> Unit = {}, val afterLexing: Pipeline.(Sequence) -> Unit = {}, val afterParsing: Pipeline.(AstRoot) -> Unit = {}, val afterExpanding: Pipeline.(AstRoot) -> Unit = {}, val afterTreeTraversal: Pipeline.(AstRoot) -> Unit = {}, val afterRendering: Pipeline.(CharSequence) -> Unit = {}, val afterPostRendering: Pipeline.(CharSequence) -> Unit = {}, val afterAllRendering: Pipeline.(CharSequence) -> Unit = {}, val afterGeneratingResources: Pipeline.(Set) -> Unit = {}, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/PipelineOptions.kt ================================================ package com.quarkdown.core.pipeline import com.quarkdown.core.document.sub.SubdocumentOutputNaming import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.core.pipeline.error.PipelineErrorHandler import java.io.File /** * Read-only settings that affect different behaviors of a [Pipeline]. * @param resourceName name of the output resource, that overrides the value of `.docname` set in the document * @param prettyOutput whether the rendering stage should produce pretty output code * @param wrapOutput whether the rendered code should be wrapped in a template code. * For example, an HTML wrapper may add `......`, * with the actual content injected in `body` * @param workingDirectory the starting directory to use when resolving relative paths from function calls. * Note: subdocuments may have different working directories. For consistent results rely on [com.quarkdown.core.context.file.FileSystem.workingDirectory] * @param enableMediaStorage whether media storage should be enabled. * If enabled, media objects referenced in the document are copied to the output directory * and those elements that use them (e.g. images) automatically reference the new local path. * This doesn't take effect with the base Markdown flavor, * as the media architecture is defined by Quarkdown through a [com.quarkdown.core.context.hooks.MediaStorerHook]. * If this is disabled, [MediaStorageOptions] are ignored. * @param subdocumentNaming the strategy used to determine subdocument output file names * @param mediaStorageOptionsOverrides rules that override the default behavior of the media storage system * @param errorHandler the error handler strategy to use when an error occurs in the pipeline, during the processing of a Quarkdown file * @param serverPort port to communicate with the local server on. If not set, no server communication is performed. In a practical scenario, * this is injected into JavaScript to communicate with the server, for example to enable dynamic reloading. */ data class PipelineOptions( val resourceName: String? = null, val prettyOutput: Boolean = false, val wrapOutput: Boolean = true, val workingDirectory: File? = null, val enableMediaStorage: Boolean = true, val subdocumentNaming: SubdocumentOutputNaming = SubdocumentOutputNaming.FILE_NAME, val serverPort: Int? = null, val mediaStorageOptionsOverrides: MediaStorageOptions = ReadOnlyMediaStorageOptions(), val errorHandler: PipelineErrorHandler = BasePipelineErrorHandler(), ) { /** * Whether the pipeline could communicate with a server. * If `false`, no server communication is performed. */ val useServer: Boolean get() = serverPort != null } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/Pipelines.kt ================================================ package com.quarkdown.core.pipeline import com.quarkdown.core.context.Context /** * Storage for keeping track of active pipelines. */ object Pipelines { /** * 1-1 associations between contexts and their pipeline. */ private val pipelines: MutableMap = mutableMapOf() /** * @param context context to retrieve the pipeline from * @return the pipeline attached to [context], if it exists. * A context can only have up to one attached pipeline. */ fun getAttachedPipeline(context: Context): Pipeline? = pipelines[context] /** * Attaches a pipeline to a context. * @param context context to attach the pipeline to * @param pipeline pipeline to attach * @throws IllegalStateException if [context] already has an attached pipeline */ fun attach( context: Context, pipeline: Pipeline, ) { if (context in pipelines) { throw IllegalStateException("Context already has an attached pipeline.") } pipelines[context] = pipeline } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/BasePipelineErrorHandler.kt ================================================ package com.quarkdown.core.pipeline.error import com.quarkdown.core.function.Function import com.quarkdown.core.log.Log /** * Simple pipeline error handler that logs the error message. */ class BasePipelineErrorHandler : PipelineErrorHandler { override fun handle( error: PipelineException, sourceFunction: Function<*>?, action: () -> T, ): T { val message = error.message ?: "Unknown error" Log.error(message) return action() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/IOPipelineException.kt ================================================ package com.quarkdown.core.pipeline.error import com.quarkdown.core.IO_ERROR_EXIT_CODE /** * Errors thrown when an I/O error occurs in the pipeline. */ class IOPipelineException( message: String, ) : PipelineException(message, IO_ERROR_EXIT_CODE) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/PipelineErrorHandler.kt ================================================ package com.quarkdown.core.pipeline.error import com.quarkdown.core.function.Function /** * Strategy used to handle errors that may occur across the pipeline. */ interface PipelineErrorHandler { /** * Handles an exception thrown during any stage of the pipeline. * @param error exception to handle * @param sourceFunction function that threw the error, if it was thrown inside a function call * @param action additional custom error handler * @see BasePipelineErrorHandler * @see StrictPipelineErrorHandler * @return the result of the action */ fun handle( error: PipelineException, sourceFunction: Function<*>?, action: () -> T, ): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/PipelineException.kt ================================================ package com.quarkdown.core.pipeline.error import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.context.Context import com.quarkdown.core.function.error.FunctionException import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.core.util.node.toPlainText /** * An exception thrown during any stage of the pipeline. * @param richMessage formatted message to display. The actual [Exception] message is the plain text of it * @param code error code. If the program is running in strict mode and thus is killed, * it defines the process exit code */ open class PipelineException( val richMessage: InlineContent, val code: Int, ) : Exception(richMessage.toPlainText()) { constructor(message: String, code: Int) : this(buildInline { text(message) }, code) } /** * Converts [this] exception to a renderable [Node], and performs the error handling provided by the [errorHandler] strategy. * @param errorHandler strategy to handle the error * @return [this] exception as a renderable [Node] */ fun PipelineException.asNode(errorHandler: PipelineErrorHandler): Node { // The function that the error originated from, if any. val sourceFunction = (this as? FunctionException)?.function return errorHandler.handle(this, sourceFunction) { Box.error( message = this.richMessage, title = sourceFunction?.name, sourceText = (this as? InvalidFunctionCallException) ?.call ?.sourceNode ?.sourceText ?.trim(), ) } } /** * @param context context to use to retrieve the error handler from * @throws [this] exception if the context does not have an attached pipeline to retrieve the error handler from * @see asNode */ fun PipelineException.asNode(context: Context): Node = context.attachedPipeline ?.options ?.errorHandler ?.let(::asNode) ?: throw this ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/StrictPipelineErrorHandler.kt ================================================ package com.quarkdown.core.pipeline.error import com.quarkdown.core.function.Function import com.quarkdown.core.log.Log /** * Pipeline error handler that rethrows the incoming error and ignores the additional custom action. * In a regular pipeline, this will cause the program to exit (see `QuarkdownCli` from the `cli` module). */ class StrictPipelineErrorHandler : PipelineErrorHandler { override fun handle( error: PipelineException, sourceFunction: Function<*>?, action: () -> T, ): Nothing { Log.error("An error occurred while in strict mode (error code ${error.code})") sourceFunction?.let { Log.error("Originated from function: ${it.name}") } throw error } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/error/UnattachedPipelineException.kt ================================================ package com.quarkdown.core.pipeline.error /** * Error thrown when trying to access the pipeline of a context that does not have an attached pipeline. */ class UnattachedPipelineException : IllegalStateException("Context does not have an attached pipeline") ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/ArtifactType.kt ================================================ package com.quarkdown.core.pipeline.output /** * Possible types of content an [OutputArtifact] contains. */ enum class ArtifactType { HTML, CSS, JAVASCRIPT, JSON, PLAIN_TEXT, /** * Quarkdown source file (QD) */ QUARKDOWN, /** * In case the artifact name includes a file extension, the type does not need to be specified. */ AUTO, } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/BinaryOutputArtifact.kt ================================================ package com.quarkdown.core.pipeline.output import java.io.File /** * Represents an [OutputResource] that contains binary data. * @param name name of the resource (without file extensions) * @param content binary content of the resource * @param type type of content the resource contains */ data class BinaryOutputArtifact( override val name: String, override val content: List, override val type: ArtifactType, ) : OutputArtifact> { override fun accept(visitor: OutputResourceVisitor): T = visitor.visit(this) companion object { /** * Creates a [BinaryOutputArtifact] from a file. * @param file file to read the content from * @return a [BinaryOutputArtifact] with the file's name and content */ fun fromFile(file: File): BinaryOutputArtifact = BinaryOutputArtifact( name = file.name, content = file.readBytes().toList(), type = ArtifactType.AUTO, ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/LazyOutputArtifact.kt ================================================ package com.quarkdown.core.pipeline.output import com.quarkdown.core.pipeline.error.IOPipelineException import com.quarkdown.core.pipeline.output.LazyOutputArtifact.Companion.internal import kotlin.reflect.KClass /** * Represents a [BinaryOutputArtifact] whose content is lazily loaded on demand (via [accept]). * @param name name of the resource (without file extensions) * @param content supplier of the content of the resource, retrieved upon visit * @param type type of content the resource contains */ data class LazyOutputArtifact( override val name: String, override val content: () -> List, override val type: ArtifactType, ) : OutputArtifact<() -> List> { // When visited, the content is loaded and a [BinaryOutputArtifact] is created and visited instead. override fun accept(visitor: OutputResourceVisitor): T = visitor.visit(BinaryOutputArtifact(name, content(), type)) companion object { private fun readInternalBytes( resource: String, referenceClass: KClass<*> = LazyOutputArtifact::class, ): List? = referenceClass.java .getResource(resource) ?.readBytes() ?.toList() /** * Creates a [LazyOutputArtifact] whose content is extracted from an internal resource. * @param resource path to the internal resource * @param name name of the resource (without file extensions) * @param type type of content the resource contains * @param referenceClass reference classpath to use to retrieve the internal resource */ fun internal( resource: String, name: String, type: ArtifactType, referenceClass: KClass<*> = LazyOutputArtifact::class, ) = LazyOutputArtifact( name, content = { readInternalBytes(resource, referenceClass) ?: throw IOPipelineException("Resource $resource not found") }, type, ) /** * Like [internal], but reads the resource instantly and returns `null` if it does not exist. * @see internal */ fun internalOrNull( resource: String, name: String, type: ArtifactType, referenceClass: KClass<*> = LazyOutputArtifact::class, ): LazyOutputArtifact? = readInternalBytes(resource, referenceClass)?.let { LazyOutputArtifact(name, content = { it }, type) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/OutputArtifact.kt ================================================ package com.quarkdown.core.pipeline.output /** * Represents an [OutputResource] that contains data. * When visited by a [FileResourceExporter], this resource is exported to a file * whose extension is determined by the resource's [type]. * @param T type of data the resource contains * @see TextOutputArtifact * @see BinaryOutputArtifact * @see LazyOutputArtifact */ interface OutputArtifact : OutputResource { /** * Content data of the resource. */ val content: T /** * Type of content the resource contains. */ val type: ArtifactType } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/OutputResource.kt ================================================ package com.quarkdown.core.pipeline.output /** * Abstraction of an output entity produced by the pipeline. * A resource is saved to file via a [FileResourceExporter]. */ interface OutputResource { /** * Name of the resource (without file extensions). */ val name: String /** * Accepts a [visitor] to perform operations on the resource. * @param visitor visitor to accept * @return result of the visit operation */ fun accept(visitor: OutputResourceVisitor): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/OutputResourceGroup.kt ================================================ package com.quarkdown.core.pipeline.output /** * Represents an [OutputResource] that contains other resources (and does not provide content of its own). * When visited by a [FileResourceExporter], this resource is exported to a directory. * @param name name of the resource (without file extensions) * @param resources sub-resources this group contains */ data class OutputResourceGroup( override val name: String, val resources: Set, ) : OutputResource { override fun accept(visitor: OutputResourceVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/OutputResourceVisitor.kt ================================================ package com.quarkdown.core.pipeline.output /** * Visitor for [OutputResource] types. * @param T return type of visit operations */ interface OutputResourceVisitor { fun visit(artifact: TextOutputArtifact): T fun visit(artifact: BinaryOutputArtifact): T fun visit(group: OutputResourceGroup): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/TextOutputArtifact.kt ================================================ package com.quarkdown.core.pipeline.output /** * Represents an [OutputResource] that contains text data. * @param name name of the resource (without file extensions) * @param content content of the resource * @param type type of content the resource contains */ data class TextOutputArtifact( override val name: String, override val content: CharSequence, override val type: ArtifactType, ) : OutputArtifact { override fun accept(visitor: OutputResourceVisitor): T = visitor.visit(this) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/visitor/CopyOutputResourceVisitor.kt ================================================ package com.quarkdown.core.pipeline.output.visitor import com.quarkdown.core.pipeline.output.BinaryOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.OutputResourceVisitor import com.quarkdown.core.pipeline.output.TextOutputArtifact /** * [OutputResourceVisitor] that creates a copy of the visited resource with a new name. * @param name new name for the copied resource */ class CopyOutputResourceVisitor( private val name: String, ) : OutputResourceVisitor { override fun visit(artifact: TextOutputArtifact): OutputResource = artifact.copy(name = name) override fun visit(artifact: BinaryOutputArtifact): OutputResource = artifact.copy(name = name) override fun visit(group: OutputResourceGroup): OutputResource = group.copy(name = name) } /** * Creates a copy of [this] resource with the specified [name]. * @param name new name for the copied resource * @return a copy of [this] resource with the specified name */ fun OutputResource.copy(name: String = this.name): OutputResource = accept(CopyOutputResourceVisitor(name)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/output/visitor/FileResourceExporter.kt ================================================ package com.quarkdown.core.pipeline.output.visitor import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.BinaryOutputArtifact import com.quarkdown.core.pipeline.output.OutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.OutputResourceVisitor import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter.NameProvider.fileNameWithoutExtension import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter.NameProvider.fullFileName import com.quarkdown.core.util.sanitizeFileName import java.io.File /** * A visitor that saves each type of [OutputResource] to a file and returns it. * @param location directory to save the resources to */ class FileResourceExporter( private val location: File, private val write: Boolean = true, ) : OutputResourceVisitor { /** * Mapping of [OutputResource]s to their file names. */ object NameProvider { /** * Given a string, returns a sanitized version of it to be used as a valid file name. * @see sanitizeFileName */ internal fun stringToFileName(string: String): String = string.sanitizeFileName(replacement = "-") /** * Name of the corresponding file of this resource, without the extension, * with symbols removed and spaces replaced with dashes. */ val OutputResource.fileNameWithoutExtension: String get() = stringToFileName(name) /** * File extension relative to the [ArtifactType] of this resource. */ val OutputArtifact<*>.fileExtension: String get() = when (type) { ArtifactType.HTML -> ".html" ArtifactType.CSS -> ".css" ArtifactType.JAVASCRIPT -> ".js" ArtifactType.JSON -> ".json" ArtifactType.PLAIN_TEXT -> ".txt" ArtifactType.QUARKDOWN -> ".qd" ArtifactType.AUTO -> "" // Assumes the file name already contains an extension. } /** * Full name of the file, including the extension relative to the [ArtifactType] of this resource. */ val OutputArtifact<*>.fullFileName: String get() = fileNameWithoutExtension + fileExtension } /** * Saves an [OutputArtifact] to a file with text content. * @return the file itself */ override fun visit(artifact: TextOutputArtifact) = File(location, artifact.fullFileName).also { if (write) { it.parentFile?.mkdirs() it.writeText(artifact.content.toString()) } } override fun visit(artifact: BinaryOutputArtifact) = File(location, artifact.fullFileName).also { if (write) { it.parentFile?.mkdirs() it.writeBytes(artifact.content.toByteArray()) } } /** * Saves an [OutputResourceGroup] to a directory which contains its nested files. * @return the directory file itself */ override fun visit(group: OutputResourceGroup): File { val directory = File(location, group.fileNameWithoutExtension) // The directory is not created if it has no content. if (group.resources.isEmpty()) { return directory } if (write) directory.mkdirs() // Saves the subfiles in the new directory. group.resources.forEach { it.accept(FileResourceExporter(directory, write)) } return directory } } /** * Saves [this] resource to file in a [directory]. * @see FileResourceExporter * @return the saved file */ fun OutputResource.saveTo(directory: File): File = accept(FileResourceExporter(location = directory)) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stage/PeekPipelineStage.kt ================================================ package com.quarkdown.core.pipeline.stage /** * A specialized pipeline stage that "peeks" at the input without modifying it. * * This interface is useful for stages that need to perform some operation on the input * (such as validation, logging, or side effects) but don't need to transform it into a different object. * * The [process] method is implemented to call [peek] and then return the input unchanged. * * @param T the type of both the input and output */ interface PeekPipelineStage : PipelineStage { /** * Processes the input by calling [peek] and then returning the input unchanged. * * @param input the input to process * @param data shared data that is passed between pipeline stages * @return the input, unchanged */ override fun process( input: T, data: SharedPipelineData, ): T { peek(input, data) return input } /** * Peeks at the input without modifying it. * * This method is called by [process] and should perform any operations needed * on the input without changing it. * * @param input the input to peek at * @param data shared data that is passed between pipeline stages */ fun peek( input: T, data: SharedPipelineData, ) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stage/PipelineStage.kt ================================================ package com.quarkdown.core.pipeline.stage import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineHooks /** * Represents a stage in the document processing pipeline. * * Each pipeline stage takes an input of type [I], processes it, and produces an output of type [O]. * Pipeline stages can be chained together to form a complete processing pipeline. * * Pipeline stages can also define hooks that are invoked after the stage completes processing, * allowing for custom behavior to be executed at specific points in the pipeline. */ interface PipelineStage { /** * A hook function that is invoked after the stage completes processing. * * The hook is a function that takes a [PipelineHooks] object and returns a function, * which takes a [Pipeline] and the output [O] of this stage. This allows for custom * behavior to be executed at specific points in the pipeline. * * If `null`, no hook will be invoked after this stage. */ val hook: ((PipelineHooks) -> Pipeline.(O) -> Unit)? /** * Processes the input [I] and produces an output [O]. * * This is the main method that implements the stage's processing logic. * * @param input the input to process * @param data shared data that is passed between pipeline stages * @return the processed output */ fun process( input: I, data: SharedPipelineData, ): O /** * Executes this pipeline stage with the given input and shared data. * * This method calls the [process] method to process the input and produce an output, * then invokes the hook function (if one is defined) using [invokeHook]. * * This is the main entry point for executing a pipeline stage. * * @param input the input to process * @param data shared data that is passed between pipeline stages * @return the processed output */ fun execute( input: I, data: SharedPipelineData, ): O = process(input, data).also { invokeHook(data, input, it) } /** * Invokes the hook function for this stage, if one is defined. * * This method is called after the stage's [process] method completes. It invokes * the hook function with the pipeline's hooks and the output of the stage. * * The hook is invoked for both the pipeline's hooks and the hooks of all registered libraries. * * @param data shared data that is passed between pipeline stages * @param input the input that was processed * @param output the output that was produced */ fun invokeHook( data: SharedPipelineData, input: I, output: O, ) { val pipeline = data.pipeline fun invokeHook(hooks: PipelineHooks) { hook?.invoke(hooks)?.invoke(pipeline, output) } // Invoke the hook of this pipeline. pipeline.hooks?.let(::invokeHook) // Invoke the hook of all the registered libraries. pipeline.libraries.forEach { library -> library.hooks?.let(::invokeHook) } } } /** * Utility function to execute a pipeline stage that takes `Unit` as input. * * This is a convenience function for pipeline stages that don't require any input * other than the shared pipeline data. * * @param data shared data that is passed between pipeline stages * @return the output of the pipeline stage */ fun PipelineStage.execute(data: SharedPipelineData): O = execute(Unit, data) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stage/PipelineStageChain.kt ================================================ package com.quarkdown.core.pipeline.stage /** * Chains two pipeline stages together to form a new pipeline stage. * * Given two stages, `A -> B` and `B -> C`, this operator produces a new stage `A -> C` * * This operator allows pipeline stages to be composed in a fluent manner: * ``` * val combinedStage = stage1 then stage2 then stage3 * ``` * * The output of the first stage becomes the input to the second stage, * and the resulting pipeline stage takes the input of the first stage * and produces the output of the second stage. * * @param next the next pipeline stage to execute after this one * @return a new pipeline stage that executes this stage followed by the next stage * @see com.quarkdown.core.pipeline.PipelineChainFactory */ infix fun PipelineStage.then(next: PipelineStage): PipelineStage = object : PipelineStage { override val hook = null override fun process( input: A, data: SharedPipelineData, ): C { val intermediate = this@then.execute(input, data) return next.execute(intermediate, data) } } /** * Conditionally chains this pipeline stage with [next] if it is not null. * * - If [next] is not null, this behaves like [then], chaining the stages together. * - If [next] is null, this stage is returned unchanged. */ infix fun PipelineStage.thenOptionally(next: PipelineStage?): PipelineStage = if (next != null) { this then next } else { this } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stage/SharedPipelineData.kt ================================================ package com.quarkdown.core.pipeline.stage import com.quarkdown.core.context.MutableContext import com.quarkdown.core.pipeline.Pipeline /** * Shared data that is passed between pipeline stages during execution. * * This data is passed to each stage's [PipelineStage.process] method, allowing stages * to access and modify shared state as needed. * * @param pipeline the pipeline instance executing the stages * @param context the mutable context containing state and configuration */ data class SharedPipelineData( val pipeline: Pipeline, val context: MutableContext, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/AfterAllRenderingPeek.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PeekPipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Peek stage that runs after all rendering has been completed. * * - It usually matches with [PipelineHooks.afterPostRendering]. * - If post-rendering is disabled by [com.quarkdown.core.pipeline.PipelineOptions.wrapOutput] set to `false`, * it will match with [PipelineHooks.afterRendering] instead. * * This stage allows for consistently hooking into the final rendered output, * regardless of whether post-rendering is enabled or not. */ object AfterAllRenderingPeek : PeekPipelineStage { override val hook = PipelineHooks::afterAllRendering override fun peek( input: CharSequence, data: SharedPipelineData, ) {} } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/AttachmentStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.pipeline.Pipelines import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for attaching a pipeline to the document context. * * This stage takes no specific input (Unit) and produces a Boolean output indicating * whether the attachment processing was successfully created if it was missing. * * In case the context is a scope context (e.g. for subdocuments), * the pipeline will likely already be attached from the parent context. * In this case, the output will be false, and hooks won't be invoked. */ object AttachmentStage : PipelineStage { override val hook = null override fun process( input: Unit, data: SharedPipelineData, ): Boolean { val uninitialized = data.context.attachedPipeline == null Pipelines.attach(data.context, data.pipeline) return uninitialized } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/AttributesUpdateStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.pipeline.stage.PeekPipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for updating attributes in the AST and context. * * - Sets the root of the AST * - Registers the current subdocument in the subdocument graph * - Updates the context's [MediaStorageOptions]. */ class AttributesUpdateStage( private val preferredMediaStorageOptions: MediaStorageOptions, ) : PeekPipelineStage { override val hook = null override fun peek( input: AstRoot, data: SharedPipelineData, ) { val context = data.context context.attributes.root = input context.subdocumentGraph = context.subdocumentGraph.addVertex(context.subdocument) // The chosen renderer has its own preferred media storage options. // For example, HTML requires local media to be accessible from the file system, // hence local files must be stored and copied to the output directory. // It does not require remote media to be stored, as they are linked to from the web. // On the other hand, for example, LaTeX rendering (not yet supported) would require // all media to be stored locally, as it does not support remote media. // // The options are merged: if a rule is already set by the user, it is not overridden. // These options must be set before traversing the tree, as media is stored during it. context.options.mergeMediaStorageOptions(this.preferredMediaStorageOptions) // The user can further force override the media storage options. context.options.mergeMediaStorageOptions(data.pipeline.options.mediaStorageOptionsOverrides) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/FunctionCallExpansionStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.function.call.FunctionCallNodeExpander import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PeekPipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for expanding function calls in the abstract syntax tree (AST). * * This stage traverses the AST and expands any function calls found in the document. * * Function calls are special constructs in the document that invoke functions defined * in libraries. These functions can generate content, modify the document structure, * or perform other operations. * * This stage is crucial for implementing the extensibility of the document format, * allowing users to define and use custom functions in their documents. */ object FunctionCallExpansionStage : PeekPipelineStage { override val hook = PipelineHooks::afterExpanding override fun peek( input: AstRoot, data: SharedPipelineData, ) { FunctionCallNodeExpander( data.context, errorHandler = data.pipeline.options.errorHandler, ).expandAll() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/LexingStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.function.library.Library import com.quarkdown.core.lexer.Token import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for lexical analysis (tokenization) of the input text. * * This stage produces a lazy sequence of tokens as output from the provided source text. * It uses the lexer factory from the context's flavor to create a block lexer that * processes the source text and breaks it down into tokens. * * The tokens produced by this stage are used by the [ParsingStage] to build an abstract syntax tree. * * @param source the raw input text to be tokenized */ class LexingStage( private val source: CharSequence, ) : PipelineStage, Sequence> { override val hook = PipelineHooks::afterLexing override fun process( input: Set, data: SharedPipelineData, ): Sequence = data.context.flavor.lexerFactory .newBlockLexer(this.source) .tokenize() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/LibrariesRegistrationStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.LibraryRegistrant import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for registering libraries with the document context. * * This stage takes a boolean as input (indicating whether to register libraries) * and produces a Set of [Library] objects as output. If the input is true, it * registers all libraries from the pipeline with the context. * * If the input is `false` (see [AttachmentStage]), it means the context is a scope context * and libraries should not be registered again, since they are inherited from the parent context. * * Libraries provide functions, extensions, and other capabilities to the document * processing system. They need to be registered with the context so that their * functions can be called from within the document. */ object LibrariesRegistrationStage : PipelineStage> { override val hook = PipelineHooks::afterRegisteringLibraries override fun process( input: Boolean, data: SharedPipelineData, ): Set { if (input) { LibraryRegistrant(data.context).registerAll(data.pipeline.libraries) } return data.pipeline.libraries } override fun invokeHook( data: SharedPipelineData, input: Boolean, output: Set, ) { if (input) { super.invokeHook(data, true, output) } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/ParsingStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.Node import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.acceptAll import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.visitor.token.TokenVisitor /** * Pipeline stage responsible for parsing tokens into an abstract syntax tree (AST). * * This stage takes a sequence of tokens (produced by the [LexingStage]) as input and * produces an [AstRoot] as output. * * The AST represents the hierarchical structure of the document and is used by * subsequent stages for further processing and rendering. */ object ParsingStage : PipelineStage, AstRoot> { override val hook = PipelineHooks::afterParsing override fun process( input: Sequence, data: SharedPipelineData, ): AstRoot { val parser: TokenVisitor = data.context.flavor.parserFactory .newParser(data.context) return AstRoot(children = input.acceptAll(parser)) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/PostRenderingStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.rendering.PostRenderer /** * Pipeline stage responsible for post-processing the rendered output. * * This stage takes a [CharSequence] (produced by the [RenderingStage]) as input and * produces a [CharSequence] as output, wrapping the rendered content into a template using a [PostRenderer]. */ class PostRenderingStage( private val postRenderer: PostRenderer, ) : PipelineStage { override val hook = PipelineHooks::afterPostRendering override fun process( input: CharSequence, data: SharedPipelineData, ): CharSequence = this.postRenderer.wrap(input) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/RenderingStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.rendering.NodeRenderer /** * Pipeline stage responsible for rendering the abstract syntax tree (AST) into a text output. * * This stage takes an [AstRoot] (produced by the [ParsingStage]) as input and * produces a [CharSequence] as output. It uses a [NodeRenderer] to visit the AST * and render it into the target format (e.g., HTML). */ class RenderingStage( private val renderer: NodeRenderer, ) : PipelineStage { override val hook = PipelineHooks::afterRendering override fun process( input: AstRoot, data: SharedPipelineData, ): CharSequence = this.renderer.visit(input) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/ResourceGenerationStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.SubdocumentContext import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.rendering.PostRenderer /** * Pipeline stage responsible for generating output resources from the rendered content. * * This stage takes the [CharSequence] output produced by the [PostRenderingStage] as input and * produces a set of [OutputResource]s as output. * * Resources are generated by the [PostRenderer], and also processes subdocuments to generate their own. * * The resources generated by this stage include: * - The main document content * - Media resources (images, fonts, etc.) * - Resources from subdocuments */ class ResourceGenerationStage( private val postRenderer: PostRenderer, ) : PipelineStage> { override val hook = PipelineHooks::afterGeneratingResources override fun process( input: CharSequence, data: SharedPipelineData, ): Set { // Resources generated by non-root subdocuments. val subdocumentResources = generateSubdocumentResources(data.pipeline, data.context) // Resources generated by the main document. val mainResourceContent = this.postRenderer.generateResources(input) return mainResourceContent + subdocumentResources } /** * Evaluates subdocuments and generates their output resources. */ private fun generateSubdocumentResources( pipeline: Pipeline, context: MutableContext, ): Set = context.sharedSubdocumentsData.graph .visitNeighbors( context.subdocument, onVisit = { context.sharedSubdocumentsData = context.sharedSubdocumentsData.copy(graph = it) }, ).asSequence() .filterIsInstance() .flatMap { nextSubdocument -> val subContext = SubdocumentContext(parent = context, subdocument = nextSubdocument) context.sharedSubdocumentsData = context.sharedSubdocumentsData.addContext(nextSubdocument, subContext) pipeline.copy(subContext).executeUnwrapped(nextSubdocument.content) }.toSet() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/pipeline/stages/TreeTraversalStage.kt ================================================ package com.quarkdown.core.pipeline.stages import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.stage.PeekPipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData /** * Pipeline stage responsible for traversing the abstract syntax tree (AST). * * This stage uses a tree iterator to traverse the AST and perform operations on it. * * @see com.quarkdown.core.context.hooks for tree traversal hooks. */ object TreeTraversalStage : PeekPipelineStage { override val hook = PipelineHooks::afterTreeTraversal override fun peek( input: AstRoot, data: SharedPipelineData, ) { data.context.flavor.treeIteratorFactory .default(data.context) .traverse(input) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/property/AssociatedProperties.kt ================================================ package com.quarkdown.core.property /** * Associations between a key of type [T] and a [PropertyContainer]. * * Example usage: * ```kotlin * val properties: AssociatedProperties = ... * val key: K = ... * val value = properties.of(key)[MyProperty] * ``` * * @param T the type of key elements * @param V the type of values in the [PropertyContainer] * @see MutableAssociatedProperties * @see PropertyContainer * @see Property * @see com.quarkdown.core.ast.attributes.AstAttributes.properties for an example of usage */ interface AssociatedProperties { /** * Retrieves the [PropertyContainer] associated with the given key, also registering an empty new one to it if it doesn't exist. * @param key the key to retrieve the [PropertyContainer] for * @return the [PropertyContainer] associated with the key */ fun of(key: T): PropertyContainer } /** * Mutable implementation of [AssociatedProperties]. */ class MutableAssociatedProperties : AssociatedProperties { private val properties: MutableMap> = mutableMapOf() override fun of(key: T): MutablePropertyContainer = properties.getOrPut(key) { MutablePropertyContainer() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/property/Property.kt ================================================ package com.quarkdown.core.property /** * A property is an atom that represents a value associated with a key, similar to a map entry. * * Example implementation: * ```kotlin * data class MyProperty(override val value: X) : Property { * companion object : Property.Key * * override val key = MyProperty * } * ``` * * @param T the type of the value associated with the key * @see PropertyContainer */ interface Property { /** * The key of the property, used to identify it. */ val key: Key /** * The value of the property. */ val value: T /** * A key type for a [Property]. * @param T the type of property that this key can be associated with */ interface Key } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/property/PropertyContainer.kt ================================================ package com.quarkdown.core.property /** * A group of properties, associated with their own key. * @param T type of the properties * @see MutablePropertyContainer * @see Property * @see AssociatedProperties * @see com.quarkdown.core.ast.attributes.AstAttributes for an example of usage */ interface PropertyContainer { /** * Retrieves a property from the container by its key. * @param key the key of the property to retrieve * @param V the type of the property, subtype of [T] * @return the property associated with the key, if any */ operator fun get(key: Property.Key): V? } /** * Mutable implementation of [PropertyContainer]. */ class MutablePropertyContainer : PropertyContainer { private val properties: MutableMap, Property> = mutableMapOf() /** * Adds a property to the container. * @param property the property to add * @param V the type of the property, subtype of [T] */ fun addProperty(property: Property) { properties[property.key] = property } /** * @see addProperty */ operator fun plusAssign(property: Property) = addProperty(property) @Suppress("UNCHECKED_CAST") // Safe to assume the property has the same generic type as the key. override operator fun get(key: Property.Key): V? = properties[key]?.value as? V } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/NodeRenderer.kt ================================================ package com.quarkdown.core.rendering import com.quarkdown.core.visitor.node.NodeVisitor /** * A rendering strategy, which converts nodes from the AST to their output code representation. */ interface NodeRenderer : NodeVisitor ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/PostRenderer.kt ================================================ package com.quarkdown.core.rendering import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.pipeline.output.OutputResource /** * Strategy used to run the post-rendering stage: * the rendered content from the rendering stage is wrapped in the document structure offered by the post-renderer. * Additionally, the post-renderer provides the output resources that can be saved to file. */ interface PostRenderer { /** * Rules that determine the default behavior of the media storage. * For example, HTML requires local media to be accessible from the file system, * hence it's preferred to copy local media to the output directory; * it's not necessary to store remote media locally. * On the other hand, for example, LaTeX rendering (not yet supported) would require * all media to be stored locally, as it does not support remote media. */ val preferredMediaStorageOptions: MediaStorageOptions /** * Wraps rendered content in the full document structure for this rendering strategy. * For example, an HTML post-renderer wraps content in `......`. * @param content the rendered content to wrap * @return the wrapped content */ fun wrap(content: CharSequence): CharSequence /** * Generates the required output resources. * Resources are abstractions of files that are generated during the rendering process and that can be saved on disk. * @param rendered the rendered content, output of the rendering stage * @return the generated output resources */ fun generateResources(rendered: CharSequence): Set /** * Given the output [resources] produced by [generateResources], merges them into a single resource * which complies with [com.quarkdown.core.pipeline.Pipeline.execute]'s output type. * * Wrapping can happen by: * - Grouping the resources into an [com.quarkdown.core.pipeline.output.OutputResourceGroup] (e.g. HTML output). * - Selecting a single resource from the set (e.g. PDF output). */ fun wrapResources( name: String, resources: Set, ): OutputResource? } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/RenderingComponents.kt ================================================ package com.quarkdown.core.rendering /** * A pair of a node renderer and a post-renderer, provided by a [com.quarkdown.core.flavor.RendererFactory]. * For example, an HTML node renderer converts a node to an HTML tag, and an HTML post-renderer wraps the rendered content in a template. * Hence, it's a good idea to pair them together to ensure consistency. * @param nodeRenderer renderer of nodes * @param postRenderer handler of the rendered content ([nodeRenderer]'s output) */ data class RenderingComponents( val nodeRenderer: NodeRenderer, val postRenderer: PostRenderer, ) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/UnsupportedRenderException.kt ================================================ package com.quarkdown.core.rendering import com.quarkdown.core.ast.Node import kotlin.reflect.KClass /** * An exception thrown when a [com.quarkdown.core.rendering.NodeRenderer] tries rendering a node which is unsupported by its flavor. * @param elementClass class of the element whose rendering was attempted */ class UnsupportedRenderException( elementClass: KClass<*>, ) : UnsupportedOperationException("${elementClass.simpleName} rendering is not supported by this flavor.") { /** * @param node node whose rendering was attempted */ constructor(node: Node) : this(node::class) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/representable/RenderRepresentable.kt ================================================ package com.quarkdown.core.rendering.representable /** * A non-node element (usually a property of a node) that can be represented in a rendered document. * * For example, the node `Stacked` can render rows and columns with a specific alignment. * In an HTML rendered documeng, the alignment type `START` is represented by the `flex-start` CSS rule. * This conversion is done by a `RenderRepresentableVisitor`. */ interface RenderRepresentable { /** * Accepts a [visitor] to produce a representation of this element suitable for the rendered document. * @param visitor visitor to accept * @param T type of the rendered representation * @return rendered representation of this element */ fun accept(visitor: RenderRepresentableVisitor): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/representable/RenderRepresentableVisitor.kt ================================================ package com.quarkdown.core.rendering.representable import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.document.layout.caption.CaptionPosition import com.quarkdown.core.document.layout.page.PageMarginPosition import com.quarkdown.core.document.layout.page.PageSide import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.document.slides.Transition import com.quarkdown.core.misc.color.Color /** * Visitor that produces representations of each [RenderRepresentable] subtype * suitable for the final rendered document. */ interface RenderRepresentableVisitor { fun visit(color: Color): T fun visit(size: Size): T fun visit(sizes: Sizes): T fun visit(alignment: Table.Alignment): T fun visit(position: CaptionPosition): T fun visit(borderStyle: Container.BorderStyle): T fun visit(alignment: Container.Alignment): T fun visit(alignment: Container.TextAlignment): T fun visit(alignment: Container.FloatAlignment): T fun visit(stackLayout: Stacked.Layout): T fun visit(alignment: Stacked.MainAxisAlignment): T fun visit(alignment: Stacked.CrossAxisAlignment): T fun visit(clip: Clipped.Clip): T fun visit(quoteType: BlockQuote.Type): T fun visit(boxType: Box.Type): T fun visit(navigationRole: NavigationContainer.Role): T fun visit(position: PageMarginPosition): T fun visit(transition: Transition.Style): T fun visit(speed: Transition.Speed): T fun visit(behavior: SlidesFragment.Behavior): T fun visit(size: TextTransformData.Size): T fun visit(weight: TextTransformData.Weight): T fun visit(style: TextTransformData.Style): T fun visit(decoration: TextTransformData.Decoration): T fun visit(case: TextTransformData.Case): T fun visit(variant: TextTransformData.Variant): T fun visit(script: TextTransformData.Script): T fun visit(pageSide: PageSide): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/tag/MultiTagBuilder.kt ================================================ package com.quarkdown.core.rendering.tag /** * Builder of multiple sub-tags from a single source. * @param renderer renderer of the sub-tags * @see TagBuilder * @see buildMultiTag */ class MultiTagBuilder( renderer: TagNodeRenderer<*>, private val pretty: Boolean, ) : TagBuilder(name = "", renderer, pretty) { override fun build() = content.toString() override fun append(content: CharSequence) { if (pretty && super.content.isNotEmpty()) super.content.append("\n") super.content.append(content) } } /** * Creates a multi-tag builder. * * Example usage: * * ``` * buildMultiTag { * +buildTag("x") { ... } * +buildTag("y") { ... } * } * ``` */ fun TagNodeRenderer<*>.buildMultiTag(init: MultiTagBuilder.() -> Unit) = MultiTagBuilder( renderer = this, pretty = this.pretty, ).also(init).build() ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/tag/TagBuilder.kt ================================================ package com.quarkdown.core.rendering.tag import com.quarkdown.core.ast.Node /** * A builder of a generic output code wrapped within tags (of any kind) which can be unlimitedly nested. * Custom `tag` methods that allow nesting builders must be provided by * the implementations, in order to allow type matching between sub-builders their parent builder, * by using a DSL-like approach. * @param name name of the root tag * @param renderer node renderer, used to add nodes directly to the code * @param pretty whether the output code should be pretty * @see tagBuilder * @see com.quarkdown.core.rendering.html.HtmlTagBuilder */ abstract class TagBuilder( private val name: String, private val renderer: TagNodeRenderer<*>, private val pretty: Boolean, ) { /** * Sub-builders for nested tags. */ protected val builders = mutableListOf() /** * Text content of the tag. */ protected val content = StringBuilder() /** * Whether this builder is empty (has no content or nested tags). */ val isEmpty: Boolean get() = content.isEmpty() && builders.isEmpty() /** * @return this builder and its nested content into stringified HTML code. */ abstract fun build(): String /** * Appends a raw string content to this builder's content buffer. * @param content string content to append */ abstract fun append(content: CharSequence) /** * Appends a string value to this tag's content. * Usage: `+"Some string"` */ operator fun CharSequence.unaryPlus() { append(this) } /** * Appends a node to this tag's content. * Their string representation is given by this [TagBuilder]'s [renderer]. * Usage: `+someNode` */ operator fun Node.unaryPlus() { +this.accept(renderer) } /** * Appends a sequence of nodes to this tag's content. * Their string representation is given by this [TagBuilder]'s [renderer]. * Usage: `+someNode.children` */ operator fun List.unaryPlus() { forEach { +it } } } /** * Creates a generic builder. * * Example usage: * * ``` * tagBuilder("name") { * +content * } * .someOption() * .build() * ``` * * @param name tag name * @param pretty whether the output code should be pretty. * Defaults to the corresponding attached pipeline option if there is one, or `false` otherwise. * @param init action to run at initialization * @return the new builder */ fun TagNodeRenderer.tagBuilder( name: String, pretty: Boolean = this.pretty, init: B.() -> Unit = {}, ) = createBuilder(name, pretty).also(init) /** * A quick way to create a simple tag builder. * Example: * ``` * tagBuilder("name", content) * .someOption() * .build() * ``` * @param name tag name * @param content nodes to render as HTML within the tag * @return the new builder * @see tagBuilder */ fun TagNodeRenderer.tagBuilder( name: String, content: List, ) = tagBuilder(name) { +content } /** * Builds a tag. * Example: * ``` * buildTag("name") { * +content * } * ``` * @param name tag name * @param init action to run at initialization * @return output code of the tag * @see tagBuilder */ fun TagNodeRenderer.buildTag( name: String, init: B.() -> Unit, ) = tagBuilder(name, init = init).build() /** * A quick way to build a simple tag. * Example: * ``` * buildTag("name", content) * ``` * @param name tag name * @param content nodes to render to output code within the tag * @return output code of the tag * @see buildTag */ fun TagNodeRenderer.buildTag( name: String, content: List, ) = buildTag(name) { +content } /** * A quick way to build a simple tag. * Example: * ``` * buildTag("name", "content") * ``` * @param name tag name * @param content string content of the tag * @return output code of the tag * @see buildTag */ fun TagNodeRenderer.buildTag( name: String, content: String, ) = buildTag(name) { +content } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/rendering/tag/TagNodeRenderer.kt ================================================ package com.quarkdown.core.rendering.tag import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.context.Context import com.quarkdown.core.rendering.NodeRenderer /** * A converter of [com.quarkdown.core.ast.Node]s into tag-based output code, * by using a DSL-like approach provided by [TagBuilder]. * @param context rendering context */ abstract class TagNodeRenderer( val context: Context, ) : NodeRenderer { /** * Whether the output code should be pretty. */ val pretty: Boolean get() = context.attachedPipeline?.options?.prettyOutput ?: false /** * Factory method that creates a new builder. * @param name name of the tag to open * @param pretty whether the output code should be pretty */ abstract fun createBuilder( name: String, pretty: Boolean, ): B /** * @param unescaped input to escape critical content for * @return the input string with the critical content escaped into safe content * (e.g. in HTML `<` is escaped to `<`). * @see CriticalContent */ abstract fun escapeCriticalContent(unescaped: String): CharSequence /** * @see escapeCriticalContent */ override fun visit(node: CriticalContent) = escapeCriticalContent(node.text) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/template/TemplateProcessor.kt ================================================ package com.quarkdown.core.template import com.quarkdown.core.util.normalizeLineSeparators import gg.jte.CodeResolver import gg.jte.ContentType import gg.jte.TemplateEngine import gg.jte.output.StringOutput import kotlin.io.path.createTempDirectory /** * A builder-like processor for a template engine backed by JTE (Java Template Engine) with `.jte` templates. * * Three main features: * * - **Values**: replace a placeholder in the template with a value. * In JTE templates, values are referenced via `${NAME}`. * * - **Conditionals**: show or hide fragments of the template code. * In JTE templates, conditionals are expressed via `@if(NAME)...@endif`. * An inverted (_not_) conditional is expressed via `@if(!NAME)...@endif`. * * - **Iterables**: repeat the content in their fragment as many times as the iterable's size, * while replacing the placeholder with the current item during each iteration. * In JTE templates, iterables are expressed via `@for(item in NAME)${item}@endfor`. * * @param text text or code of the `.jte` template */ class TemplateProcessor( private val text: String, private val values: MutableMap = mutableMapOf(), private val conditionals: MutableMap = mutableMapOf(), private val iterables: MutableMap> = mutableMapOf(), ) { /** * Adds a reference to a placeholder in the template code. * @param placeholder placeholder to replace * @param value value to replace in change of [placeholder] * @return this for concatenation */ fun value( placeholder: String, value: Any?, ) = apply { values[placeholder] = value } /** * Adds a conditional variable that shows or removes fragments of the template code. * @param conditional conditional name * @param value whether the fragment should be shown (`true`) or hidden (`false`) * @return this for concatenation */ fun conditional( conditional: String, value: Boolean, ) = apply { conditionals[conditional] = value } /** * Adds both a [value] to replace the placeholder (or `null` if absent), * and registers it so that the template can check for `null` presence. * @param placeholder both placeholder to replace and name of the conditional * @param value value to replace in change of the placeholder, or `null` if absent * @return this for concatenation * @see value */ fun optionalValue( placeholder: String, value: Any?, ) = value(placeholder, value) /** * Adds an iterable to replace a placeholder in the template code. * @param placeholder placeholder to replace * @param iterable iterable to replace in change of [placeholder] * @return this for concatenation */ fun iterable( placeholder: String, iterable: Iterable, ) = apply { iterables[placeholder] = iterable } /** * Creates a copy of this template processor with the same injected properties. * @param text new text the template * @return a new [TemplateProcessor] with the same injections, and the new text */ fun copy(text: String = this.text) = TemplateProcessor( text, values.toMutableMap(), conditionals.toMutableMap(), iterables.toMutableMap(), ) /** * Builds a unified parameter map from the registered values, conditionals, and iterables, * suitable for passing to the JTE template engine. * * When a key exists in multiple maps, values and iterables take precedence over conditionals, * since templates can check values for `null` and iterables for emptiness directly, * making the standalone boolean redundant. */ private fun buildParams(): Map = buildMap { putAll(conditionals) putAll(this@TemplateProcessor.values) putAll(iterables) } /** * @return the original template [text], with all placeholders and conditionals processed into the final output */ fun process(): CharSequence { val normalizedText = text.normalizeLineSeparators().toString() val params = buildParams() val templateName = "template.jte" val codeResolver = object : CodeResolver { override fun resolve(name: String): String = normalizedText override fun getLastModified(name: String): Long = 0L override fun exists(name: String): Boolean = name == templateName } val engine = TemplateEngine.create( codeResolver, createTempDirectory("jte"), ContentType.Plain, ) engine.setTrimControlStructures(true) val output = StringOutput() engine.render(templateName, params, output) return output.toString().trimEnd() } companion object { /** * @param name name of the internal resource * @param referenceClass reference classpath to use to retrieve the internal resource * @return a new [TemplateProcessor] with its template loaded from the resource content * @throws IllegalStateException if the resource cannot be found */ fun fromResourceName( name: String, referenceClass: Class<*> = TemplateProcessor::class.java, ) = TemplateProcessor( referenceClass .getResourceAsStream(name) ?.reader() ?.readText() ?: throw IllegalStateException("Cannot find wrapper resource $name."), ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/CollectionUtils.kt ================================================ package com.quarkdown.core.util /** * @param consumeAmount amount of elements to consume * @return this sequence, sliced after the first [consumeAmount] elements, as an iterator. */ fun Sequence.iterator(consumeAmount: Int): Iterator = drop(consumeAmount).iterator() /** * @return the next element if it exists, `null` otherwise */ fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null /** * @return [this] sequence where the second element of each pair is not `null` */ @Suppress("UNCHECKED_CAST") fun Sequence>.filterNotNullValues(): Sequence> = this .filter { it.second != null } .map { it.first to it.second!! } /** * @return [this] sequence where both elements of each pair are not `null` */ @Suppress("UNCHECKED_CAST") fun Sequence>.filterNotNullEntries(): Sequence> = this .filter { it.first != null && it.second != null } .map { it.first!! to it.second!! } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/EnumUtils.kt ================================================ package com.quarkdown.core.util /** * Name of the enum in kebab-case. * Example: `TOP_LEFT_CORNER` -> `top-left-corner` */ val Enum<*>.kebabCaseName: String get() = name.lowercase().replace("_", "-") ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/EscapeUtils.kt ================================================ package com.quarkdown.core.util import org.apache.commons.text.StringEscapeUtils /** * Represents a target (commonly a language or format) that strings can be escaped for. * * For instance: * - in HTML `<` becomes `<` * - in JavaScript `"` becomes `\"` * * This is the inverse of [UnescapeTarget]. */ sealed interface EscapeTarget { /** * Escapes the input string for the target format. * @param input the string to escape * @return the escaped string */ fun escape(input: String): String } /** * Represents a target (commonly a language or format) that strings can be unescaped from. * * For instance: * - in HTML `<` becomes `<` * - in JavaScript `\"` becomes `"` * * This is the inverse of [EscapeTarget]. */ sealed interface UnescapeTarget { /** * Unescapes the input string from the target format. * @param input the string to unescape * @return the unescaped string */ fun unescape(input: String): String } /** * Utilities for escaping and unescaping strings for various targets. */ object Escape { object Html : EscapeTarget, UnescapeTarget { override fun escape(input: String): String = StringEscapeUtils.escapeHtml4(input) override fun unescape(input: String): String = StringEscapeUtils.unescapeHtml4(input) } object JavaScript : EscapeTarget, UnescapeTarget { override fun escape(input: String): String = StringEscapeUtils.escapeEcmaScript(input) override fun unescape(input: String): String = StringEscapeUtils.unescapeEcmaScript(input) } object Json : EscapeTarget, UnescapeTarget { override fun escape(input: String): String = StringEscapeUtils.escapeJson(input) override fun unescape(input: String): String = StringEscapeUtils.unescapeJson(input) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/IOUtils.kt ================================================ package com.quarkdown.core.util import java.io.File import kotlin.io.path.Path /** * Utility methods for file-based operations. */ object IOUtils { /** * Resolves a [File] located in [path], either relative or absolute. * If the path is relative, the location is determined from the [workingDirectory]. * @param path path of the file, either relative or absolute * @param workingDirectory directory from which the file is resolved, in case the path is relative * @return a [File] instance of the file */ fun resolvePath( path: String, workingDirectory: File?, ): File = if (workingDirectory != null && !Path(path).isAbsolute) { File(workingDirectory, path) } else { File(path) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/RangeUtils.kt ================================================ package com.quarkdown.core.util /** * Returns a new [IntRange] with the specified [offset] added to both the start and end of the range. * @param offset the amount to offset the range by * @return a new [IntRange] with the offset applied */ fun IntRange.offset(offset: Int): IntRange = IntRange(first + offset, last + offset) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/ScopedCounter.kt ================================================ package com.quarkdown.core.util import java.util.concurrent.atomic.AtomicInteger /** * A thread-safe counter that tracks a depth value within nested scopes. * @param maxDepth maximum allowed depth. If exceeded, [onOverflow] is invoked * @param onOverflow action to perform when the maximum depth is exceeded */ class ScopedCounter( @PublishedApi internal val maxDepth: Int, @PublishedApi internal val onOverflow: () -> Nothing, ) { @PublishedApi internal val depth: AtomicInteger = AtomicInteger(0) /** * The current depth of the counter. */ fun get(): Int = depth.get() /** * Increments the counter, executes [block], and decrements the counter when [block] completes. * If the counter exceeds [maxDepth], [onOverflow] is invoked before executing [block]. */ inline fun incrementScoped(block: () -> T): T { val current = depth.incrementAndGet() if (current > maxDepth) { depth.decrementAndGet() onOverflow() } try { return block() } finally { depth.decrementAndGet() } } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/StringCase.kt ================================================ package com.quarkdown.core.util /** * A general purpose strategy to transform a string to a specific case type. */ sealed interface StringCase { /** * Transforms a [string] according to the case. * @return transformed string */ fun transform(string: String): String /** * Uppercase. `Hello` -> `HELLO` */ data object Upper : StringCase { override fun transform(string: String) = string.uppercase() } /** * Lowercase. `Hello` -> `hello` */ data object Lower : StringCase { override fun transform(string: String) = string.lowercase() } /** * Capitalize. `hello` -> `Hello` */ data object Capitalize : StringCase { override fun transform(string: String) = string.replaceFirstChar(Char::titlecase) } } /** * Transforms [this] string to a specific case. * @return the transformed string */ fun String.case(case: StringCase) = case.transform(this) ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/StringUtils.kt ================================================ package com.quarkdown.core.util /** * @param prefix prefix to remove * @param ignoreCase whether to ignore case when searching for the prefix * @return a pair of this string without [prefix] and a boolean value indicating whether the prefix was removed. * If the prefix is not present, the string is returned as is and the boolean value is `false` */ fun String.removeOptionalPrefix( prefix: String, ignoreCase: Boolean = false, ): Pair = if (startsWith(prefix, ignoreCase)) { substring(prefix.length) to true } else { this to false } /** * @return a sliced copy of this string from start to the last occurrence of [string] if it exists, * this string otherwise */ fun String.takeUntilLastOccurrence(string: String): String { // Trim trailing #s preceeded by a space val trailingIndex = lastIndexOf(string) return if (trailingIndex >= 0) { substring(0, trailingIndex) } else { this } } /** * @return a substring of [this] string from [startIndex] to [endIndex] if the indices are within bounds. * If [startIndex] is less than 0, the actual start index is 0. * If [endIndex] is greater than the length of the string or less than the start index, * the actual end index is the length of the string. */ fun CharSequence.substringWithinBounds( startIndex: Int, endIndex: Int, ): String { val start = startIndex.coerceAtLeast(0) return substring(start, endIndex.coerceAtMost(length).coerceAtLeast(start)) } /** * @return [this] string without the first and last characters, if possible */ fun String.trimDelimiters(): String = if (length >= 2) substring(1, length - 1) else this /** * Indents each line of [this] string by [indent]. * @param indent indentation string * @return [this] string, indented */ fun CharSequence.indent(indent: String) = buildString { this@indent .lineSequence() .filterNot { it.isEmpty() } .forEach { append(indent).append(it).append("\n") } } /** * @param count number of lines to take * @param addOmittedLinesSuffix whether to add a suffix indicating how many lines were omitted * @return the first [count] lines of [this] string, plus an optional `... (N more lines)` suffix */ fun CharSequence.takeLines( count: Int, addOmittedLinesSuffix: Boolean, ): String { if (!addOmittedLinesSuffix) { return this.lines().take(count).joinToString(separator = "\n") } val lines = this.lines() return if (lines.size <= count) { this.toString() } else { buildString { lines.take(count).forEach { appendLine(it) } appendLine("... (${lines.size - count} more lines)") } } } /** * An optimized way to replace all occurrences of [oldValue] with [newValue] in a [StringBuilder]. * @return this builder */ fun StringBuilder.replace( oldValue: String, newValue: String, ) = apply { var startIndex = indexOf(oldValue) while (startIndex >= 0) { replace(startIndex, startIndex + oldValue.length, newValue) startIndex = indexOf(oldValue, startIndex + newValue.length) } } /** * @return [this] string with all non-alphanumeric characters, * except for `-`, `_`, `@`, replaced with [replacement]. * `.` is sanitized only at the beginning and the end of the string. * @param replacement character to replace invalid characters with */ fun String.sanitizeFileName(replacement: String) = this.replace("^\\.|\\.$|[^a-zA-Z0-9\\-_.@]+".toRegex(), replacement) /** * Whether the content of [this] string between [startIndex] and the end of the first line is blank. * If [startIndex] is beyond the first line, this returns `true`. * @param startIndex index to start checking from * @return `true` if the remainder of the first line after [startIndex] is blank or [startIndex] is past the first line */ fun CharSequence.isBlankUntilEndOfLine(startIndex: Int): Boolean { val firstLineEnd = indexOf('\n').let { if (it < 0) length else it } return startIndex > firstLineEnd || substring(startIndex, firstLineEnd).isBlank() } /** * @return [this] string with line separators replaced with `\n`, * or the string itself if `\n` is already the line separator */ fun CharSequence.normalizeLineSeparators(): CharSequence = when (val separator = System.lineSeparator()) { "\n" -> this else -> this.toString().replace(separator, "\n") } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/URLUtils.kt ================================================ package com.quarkdown.core.util import java.net.MalformedURLException import java.net.URL private const val ANCHOR_DELIMITER = '#' /** * Strips the anchor (fragment) from a URL string. * @return a pair of the base URL and the anchor, or `null` if no anchor is present */ fun String.stripAnchor(): Pair? = when (val anchorIndex = indexOf(ANCHOR_DELIMITER)) { -1 -> null else -> Pair(substring(0, anchorIndex), substring(anchorIndex + 1)) } /** * @return a URL from [this] string if it's a valid URL, or `null` otherwise */ fun String.toURLOrNull(): URL? = try { URL(this) } catch (_: MalformedURLException) { null } /** * Whether [this] string is a valid URL. */ val String.isURL: Boolean get() = toURLOrNull() != null ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/NodeUtils.kt ================================================ package com.quarkdown.core.util.node import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.PlainTextNode import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.visitor.node.NodeVisitor /** * Returns a sequence of all nodes in the tree, where [this] is the root node. * The sequence is generated by traversing the tree in depth-first order. * The root node is excluded from the sequence. * * Example: * * Input (nested node tree): * ``` * AstRoot * BlockQuote * Paragraph * Text * Paragraph * Strong * Text * Text * Emphasis * Text * ``` * * Output (flattened sequence): * ``` * BlockQuote * Paragraph * Text * Paragraph * Strong * Text * Text * Emphasis * Text * ``` * * @return flattened sequence of children nodes, excluding [this] root node */ fun NestableNode.flattenedChildren(): Sequence = sequence { // DFS traversal. for (child in children) { yield(child) if (child is NestableNode) { yieldAll(child.flattenedChildren()) } } } /** * Converts processed [InlineContent] to its plain text representation. * For example, the Markdown input `foo **bar `baz`**` has `foo bar baz` as its plain text. * * Note: this is a quick and efficient implementation, practical for the use cases inside the core compiler * (e.g., stripping away formatting from image labels). * For a full-fledged plain text rendering, see the `quarkdown-plaintext` module instead. * * @param renderer optional renderer to use to render critical content and text symbols * @return plain text of the inline content * @see PlainTextNode */ fun InlineContent.toPlainText(renderer: NodeVisitor? = null): String { val builder = StringBuilder() // Visits the tree and appends the text content of each node. AstRoot(this).flattenedChildren().forEach { when (it) { is CriticalContent if renderer != null -> builder.append(renderer.visit(it)) is TextSymbol if renderer != null -> builder.append(renderer.visit(it)) is PlainTextNode -> builder.append(it.text) } } return builder.toString() } /** * Strips rich content from [this] inline content and returns a new inline content with only one [com.quarkdown.core.ast.base.inline.Text] child, * which contains the plain text representation of [this] inline content. * @param renderer optional renderer to use to render critical content and text symbols * @return inline content with only plain text * @see toPlainText */ fun InlineContent.stripRichContent(renderer: NodeVisitor? = null): InlineContent = buildInline { text(toPlainText(renderer)) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/conversion/list/MarkdownListConverter.kt ================================================ package com.quarkdown.core.util.node.conversion.list import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.function.value.factory.IllegalRawValueException /** * Helper that converts a Markdown list to a value of type [T]. * @param list list to convert * @param T type of value to convert to * @param E type of elements that compose the output value * @param N type of nodes, children of the list, that can be handled */ abstract class MarkdownListConverter( private val list: ListBlock, ) { /** * Pushes an [element] to the internal value. */ protected abstract fun push(element: E) /** * Validates the first child of a list item and converts it to a subclass of type [N]. * @param firstChild the first child of a list item * @return the validated child, converted to a [Node] subclass of type [N] * @throws IllegalRawValueException if the child is not valid */ protected abstract fun validateChild(firstChild: Node): N /** * Converts the inline child of a list item to a pushable element. * "Inline" means the list item does not contain a nested list, for example: * ``` * - Inline 1 * - Inline 2 * - Nested * - Inline 3 * ``` * @param child the first child of a list item. In the previous example, it would be a paragraph which contains "Inline 1", "Inline 2" or "Inline 3". * @return the element to push */ protected abstract fun inlineValue(child: N): E /** * Converts the nested child of a list item to a pushable element. * ``` * - Nested * - A * - B * - C * ``` * @param child the first child of a list item. In the previous example, it would be a paragraph which contains "Nested". * @param list the Markdown nested list. In the previous example, it would be a list which contains "A", "B" and "C". * @return the element to push */ protected abstract fun nestedValue( child: N, list: ListBlock, ): E /** * Wraps the pushed elements into a value of type [T]. * @return the wrapped value */ protected abstract fun wrap(): T /** * @return [list] converted to a value of type [T] * @throws IllegalRawValueException if the list is not in the correct format */ fun convert(): T { list.items .asSequence() .map { it.children.filterNot { child -> child is Newline } } .forEach { children -> val firstChild: N = validateChild(children.first()) when (val secondChild = children.getOrNull(1)) { null -> push(inlineValue(firstChild)) is ListBlock -> push(nestedValue(firstChild, secondChild)) else -> throw IllegalRawValueException("Unexpected element", secondChild) } } return wrap() } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/conversion/list/MarkdownListToCollectionValue.kt ================================================ package com.quarkdown.core.util.node.conversion.list import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.util.node.toPlainText /** * Helper that converts a Markdown list to an [OrderedCollectionValue]. * @param list list to convert * @param inlineValueMapper function that maps the node of a list item to a value * @param nestedValueMapper function that maps a nested list item to a value. * The first argument is the parent node, and the second is the nested [ListBlock] * @param T type of values in the collection * @see OrderedCollectionValue * @see ValueFactory.iterable */ class MarkdownListToCollectionValue>( list: ListBlock, inlineValueMapper: (Node) -> T, nestedValueMapper: (Node, ListBlock) -> T, ) : MarkdownListToIterable, T>(list, inlineValueMapper, nestedValueMapper) { override fun wrap(): OrderedCollectionValue = OrderedCollectionValue(elements.toList()) companion object { /** * [MarkdownListToCollectionValue] factory via a [ValueFactory]. * @param list list to convert * @param context context to use for the conversion */ fun viaValueFactory( list: ListBlock, context: Context, ): MarkdownListToCollectionValue<*> = MarkdownListToCollectionValue( list, inlineValueMapper = { when (it) { is TextNode -> ValueFactory.eval(it.text.toPlainText(), context) else -> NodeValue(it) } }, nestedValueMapper = { _, list -> viaValueFactory(list, context).convert() }, ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/conversion/list/MarkdownListToDictionaryValue.kt ================================================ package com.quarkdown.core.util.node.conversion.list import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.context.Context import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.factory.IllegalRawValueException import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.util.node.toPlainText /** * Separator between key and value in a dictionary element. */ private const val KEY_VALUE_SEPARATOR = ":" /** * Helper that converts a Markdown list to a [DictionaryValue]. * @param list list to convert * @param inlineValueMapper function that maps a raw string to a value. * This is invoked when the entry is in a `- key: value` format. * @param nestedValueMapper function that maps a nested list to a value. * This is invoked when the entry is in the format: * ``` * - key * - value * ``` * @param nothingValueMapper function that maps an empty value to a value. * This is invoked when the entry is in the format: * ``` * - key * ``` * @param T type of values in the dictionary * @see DictionaryValue * @see ValueFactory.dictionary */ class MarkdownListToDictionaryValue>( list: ListBlock, private val inlineValueMapper: (String) -> T, private val nestedValueMapper: (ListBlock) -> T, private val nothingValueMapper: () -> T, ) : MarkdownListConverter, Pair, TextNode>(list) { private val map = mutableMapOf() override fun push(element: Pair) { map[element.first] = element.second } override fun validateChild(firstChild: Node) = firstChild as? TextNode ?: throw IllegalRawValueException( "Dictionary element does not contain a key", firstChild, ) override fun inlineValue(child: TextNode): Pair { val text = child.text.toPlainText() // A key-value pair. // - This: that val parts = text.split(KEY_VALUE_SEPARATOR, limit = 2) val key = parts.first() return key to when { parts.size > 1 -> inlineValueMapper(parts[1].trimStart()) else -> nothingValueMapper() } } override fun nestedValue( child: TextNode, list: ListBlock, ): Pair { // Nested dictionary. // - This // - that: those // The key-value separator at the end of the text is optional. val key = child.text.toPlainText().removeSuffix(KEY_VALUE_SEPARATOR) return key to nestedValueMapper(list) } override fun wrap() = DictionaryValue(map) companion object { /** * [MarkdownListToDictionaryValue] factory via a [ValueFactory]. * @param list list to convert * @param context context to use for the conversion */ fun viaValueFactory( list: ListBlock, context: Context, ): MarkdownListToDictionaryValue<*> = MarkdownListToDictionaryValue( list, // Node values are currently unsupported as dictionary values. // Here we give back the raw string as a fallback in case a node is met. inlineValueMapper = { ValueFactory.eval(it, context, fallback = { it.wrappedAsValue() }) }, nestedValueMapper = { viaValueFactory(it, context).convert() }, nothingValueMapper = { DictionaryValue(mutableMapOf()) }, ) } } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/conversion/list/MarkdownListToIterable.kt ================================================ package com.quarkdown.core.util.node.conversion.list import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.list.ListBlock /** * Abstract helper that converts a Markdown list to a flat (non-keyed) output of type [O], * by mapping each list item to an element of type [T]. * * Inline and nested list syntaxes are both supported: * * - Extended: * ``` * - : * - value * ``` * (Note that the `:` character is not mandatory. Any string is valid since it's ignored by the parsing. `:` is the preferred one.) * * - Compact: * ``` * - - value * ``` * @param O type of the final converted output * @param T type of individual element values in the list * @param list list to convert * @param inlineValueMapper function that maps the node of a list item to a value. * For example, the paragraph node containing `file1.txt` in `- file1.txt` * @param nestedValueMapper function that maps a nested list item to a value. * The first argument is the parent node (e.g. the paragraph containing the label `dir1` in `- dir1`), * and the second is the nested [ListBlock] containing its children. * In the compact syntax (`- - value`), both arguments refer to the same [ListBlock] node * @see com.quarkdown.core.function.value.factory.ValueFactory.iterable */ abstract class MarkdownListToIterable( list: ListBlock, private val inlineValueMapper: (Node) -> T, private val nestedValueMapper: (Node, ListBlock) -> T, ) : MarkdownListConverter(list) { protected val elements = mutableListOf() override fun push(element: T) { elements += element } override fun validateChild(firstChild: Node) = firstChild override fun inlineValue(child: Node) = when (child) { // Compact syntax: the parent node is the list itself. is ListBlock -> nestedValueMapper(child, child) else -> inlineValueMapper(child) } // Extended syntax. override fun nestedValue( child: Node, list: ListBlock, ) = nestedValueMapper(child, list) } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/util/node/conversion/list/MarkdownListToList.kt ================================================ package com.quarkdown.core.util.node.conversion.list import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.list.ListBlock /** * Helper that converts a Markdown list to a [List]. * @param T type of values in the list * @param list list to convert * @param inlineValueMapper function that maps the node of a list item to a value * @param nestedValueMapper function that maps a nested list item to a value. * The first argument is the parent node, and the second is the nested [ListBlock] */ class MarkdownListToList( list: ListBlock, inlineValueMapper: (Node) -> T, nestedValueMapper: (Node, ListBlock) -> T, ) : MarkdownListToIterable, T>(list, inlineValueMapper, nestedValueMapper) { override fun wrap(): List = elements.toList() } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/visitor/node/NodeVisitor.kt ================================================ package com.quarkdown.core.visitor.node import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.Landscape import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.SlidesSpeakerNote import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.PageCounter import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.Whitespace import com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer import com.quarkdown.core.ast.quarkdown.invisible.PageNumberFormatter import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer import com.quarkdown.core.ast.quarkdown.reference.CrossReference /** * A visitor for [com.quarkdown.core.ast.Node]s. * @param T output type of the `visit` methods */ interface NodeVisitor { fun visit(node: AstRoot): T // Base block fun visit(node: Newline): T fun visit(node: Code): T fun visit(node: HorizontalRule): T fun visit(node: Heading): T fun visit(node: LinkDefinition): T fun visit(node: FootnoteDefinition): T fun visit(node: OrderedList): T fun visit(node: UnorderedList): T fun visit(node: ListItem): T fun visit(node: Html): T fun visit(node: Table): T fun visit(node: Paragraph): T fun visit(node: BlockQuote): T fun visit(node: BlankNode): T // Base inline fun visit(node: Comment): T fun visit(node: LineBreak): T fun visit(node: CriticalContent): T fun visit(node: Link): T fun visit(node: ReferenceLink): T fun visit(node: SubdocumentLink): T fun visit(node: ReferenceFootnote): T fun visit(node: Image): T fun visit(node: ReferenceImage): T fun visit(node: CheckBox): T fun visit(node: Text): T fun visit(node: TextSymbol): T fun visit(node: CodeSpan): T fun visit(node: Emphasis): T fun visit(node: Strong): T fun visit(node: StrongEmphasis): T fun visit(node: Strikethrough): T // Quarkdown extensions fun visit(node: FunctionCallNode): T // Quarkdown block fun visit(node: Figure<*>): T fun visit(node: PageBreak): T fun visit(node: Math): T fun visit(node: Container): T fun visit(node: Stacked): T fun visit(node: Numbered): T fun visit(node: Landscape): T fun visit(node: Clipped): T fun visit(node: Box): T fun visit(node: Collapse): T fun visit(node: Whitespace): T fun visit(node: NavigationContainer): T fun visit(node: TableOfContentsView): T fun visit(node: BibliographyView): T fun visit(node: MermaidDiagram): T fun visit(node: FileTree): T fun visit(node: SubdocumentGraph): T // Quarkdown inline fun visit(node: MathSpan): T fun visit(node: TextTransform): T fun visit(node: IconImage): T fun visit(node: InlineCollapse): T fun visit(node: PageCounter): T fun visit(node: LastHeading): T fun visit(node: CrossReference): T fun visit(node: BibliographyCitation): T fun visit(node: SlidesFragment): T fun visit(node: SlidesSpeakerNote): T // Quarkdown invisible nodes fun visit(node: PageMarginContentInitializer): T fun visit(node: PageNumberFormatter): T fun visit(node: PageNumberReset): T fun visit(node: SlidesConfigurationInitializer): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/visitor/token/BlockTokenVisitor.kt ================================================ package com.quarkdown.core.visitor.token import com.quarkdown.core.lexer.tokens.BlockCodeToken import com.quarkdown.core.lexer.tokens.BlockQuoteToken import com.quarkdown.core.lexer.tokens.BlockTextToken import com.quarkdown.core.lexer.tokens.FencesCodeToken import com.quarkdown.core.lexer.tokens.FootnoteDefinitionToken import com.quarkdown.core.lexer.tokens.FunctionCallToken import com.quarkdown.core.lexer.tokens.HeadingToken import com.quarkdown.core.lexer.tokens.HorizontalRuleToken import com.quarkdown.core.lexer.tokens.HtmlToken import com.quarkdown.core.lexer.tokens.LinkDefinitionToken import com.quarkdown.core.lexer.tokens.ListItemToken import com.quarkdown.core.lexer.tokens.MultilineMathToken import com.quarkdown.core.lexer.tokens.NewlineToken import com.quarkdown.core.lexer.tokens.OnelineMathToken import com.quarkdown.core.lexer.tokens.OrderedListToken import com.quarkdown.core.lexer.tokens.PageBreakToken import com.quarkdown.core.lexer.tokens.ParagraphToken import com.quarkdown.core.lexer.tokens.SetextHeadingToken import com.quarkdown.core.lexer.tokens.TableToken import com.quarkdown.core.lexer.tokens.UnorderedListToken /** * A visitor for block [com.quarkdown.core.lexer.Token]s. * @param T output type of the `visit` methods */ interface BlockTokenVisitor { fun visit(token: NewlineToken): T fun visit(token: BlockCodeToken): T fun visit(token: FencesCodeToken): T fun visit(token: HorizontalRuleToken): T fun visit(token: HeadingToken): T fun visit(token: SetextHeadingToken): T fun visit(token: LinkDefinitionToken): T fun visit(token: FootnoteDefinitionToken): T fun visit(token: UnorderedListToken): T fun visit(token: OrderedListToken): T fun visit(token: ListItemToken): T fun visit(token: TableToken): T fun visit(token: HtmlToken): T fun visit(token: ParagraphToken): T fun visit(token: BlockQuoteToken): T fun visit(token: BlockTextToken): T // Quarkdown extensions fun visit(token: PageBreakToken): T fun visit(token: MultilineMathToken): T fun visit(token: OnelineMathToken): T fun visit(token: FunctionCallToken): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/visitor/token/InlineTokenVisitor.kt ================================================ package com.quarkdown.core.visitor.token import com.quarkdown.core.lexer.tokens.CodeSpanToken import com.quarkdown.core.lexer.tokens.CommentToken import com.quarkdown.core.lexer.tokens.CriticalContentToken import com.quarkdown.core.lexer.tokens.DiamondAutolinkToken import com.quarkdown.core.lexer.tokens.EmphasisToken import com.quarkdown.core.lexer.tokens.EntityToken import com.quarkdown.core.lexer.tokens.EscapeToken import com.quarkdown.core.lexer.tokens.ImageToken import com.quarkdown.core.lexer.tokens.InlineMathToken import com.quarkdown.core.lexer.tokens.LineBreakToken import com.quarkdown.core.lexer.tokens.LinkToken import com.quarkdown.core.lexer.tokens.PlainTextToken import com.quarkdown.core.lexer.tokens.ReferenceFootnoteToken import com.quarkdown.core.lexer.tokens.ReferenceImageToken import com.quarkdown.core.lexer.tokens.ReferenceLinkToken import com.quarkdown.core.lexer.tokens.StrikethroughToken import com.quarkdown.core.lexer.tokens.StrongEmphasisToken import com.quarkdown.core.lexer.tokens.StrongToken import com.quarkdown.core.lexer.tokens.TextSymbolToken import com.quarkdown.core.lexer.tokens.UrlAutolinkToken /** * A visitor for inline [com.quarkdown.core.lexer.Token]s. * @param T output type of the `visit` methods */ interface InlineTokenVisitor { fun visit(token: EscapeToken): T fun visit(token: EntityToken): T fun visit(token: CriticalContentToken): T fun visit(token: TextSymbolToken): T fun visit(token: CommentToken): T fun visit(token: LineBreakToken): T fun visit(token: LinkToken): T fun visit(token: ReferenceLinkToken): T fun visit(token: ReferenceFootnoteToken): T fun visit(token: DiamondAutolinkToken): T fun visit(token: UrlAutolinkToken): T fun visit(token: ImageToken): T fun visit(token: ReferenceImageToken): T fun visit(token: CodeSpanToken): T // Emphasis fun visit(token: PlainTextToken): T fun visit(token: EmphasisToken): T fun visit(token: StrongToken): T fun visit(token: StrongEmphasisToken): T fun visit(token: StrikethroughToken): T // Quarkdown extensions fun visit(token: InlineMathToken): T } ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/visitor/token/TokenVisitor.kt ================================================ package com.quarkdown.core.visitor.token /** * A visitor for [com.quarkdown.core.lexer.Token]s. * @param T output type of the `visit` methods */ interface TokenVisitor : BlockTokenVisitor, InlineTokenVisitor ================================================ FILE: quarkdown-core/src/main/kotlin/com/quarkdown/core/visitor/token/TokenVisitorAdapter.kt ================================================ package com.quarkdown.core.visitor.token /** * A general [TokenVisitor] that delegates its visiting operations to one of its members. * @param blockVisitor visitor of block tokens * @param inlineVisitor visitor of inline tokens * @param T output type of the `visit` methods */ class TokenVisitorAdapter( blockVisitor: BlockTokenVisitor, inlineVisitor: InlineTokenVisitor, ) : TokenVisitor, BlockTokenVisitor by blockVisitor, InlineTokenVisitor by inlineVisitor ================================================ FILE: quarkdown-core/src/main/resources/log4j2.xml ================================================ ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/AstDslTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.TaskListItemVariant import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.context.file.SimpleFileSystem import kotlin.test.Test /** * Tests for the AST building DSL. * @see com.quarkdown.core.ast.dsl */ class AstDslTest { @Test fun dsl() { val root = buildBlock { root { paragraph { text("Hello, ") lineBreak() strong { codeSpan("world") } text("!") } blockQuote { paragraph { emphasis { text("Block") strong { text("quote") } image("url", "title") { strong { text("alt") } } } } +Code(content = "println(\"Hello, world!\")", language = "kotlin") } orderedList(startIndex = 1, loose = true) { listItem { paragraph { text("Item 1") } } listItem { paragraph { text("Item 2") } } } unorderedList(loose = false) { listItem { paragraph { text("Item 1") } } listItem(TaskListItemVariant(isChecked = true)) { paragraph { text("Item 2") } } } heading(3) { text("Heading") } } } assertNodeEquals( AstRoot( listOf( Paragraph( listOf( Text("Hello, "), LineBreak, Strong(listOf(CodeSpan("world"))), Text("!"), ), ), BlockQuote( children = listOf( Paragraph( listOf( Emphasis( listOf( Text("Block"), Strong(listOf(Text("quote"))), Image( link = Link( listOf(Strong(listOf(Text("alt")))), url = "url", title = "title", fileSystem = SimpleFileSystem(), ), width = null, height = null, ), ), ), ), ), Code("println(\"Hello, world!\")", "kotlin"), ), ), OrderedList( startIndex = 1, isLoose = true, children = listOf( ListItem(children = listOf(Paragraph(listOf(Text("Item 1"))))), ListItem(children = listOf(Paragraph(listOf(Text("Item 2"))))), ), ), UnorderedList( isLoose = false, children = listOf( ListItem(children = listOf(Paragraph(listOf(Text("Item 1"))))), ListItem( listOf(TaskListItemVariant(isChecked = true)), children = listOf(Paragraph(listOf(Text("Item 2")))), ), ), ), Heading(3, listOf(Text("Heading"))), ), ), root, ) } @Test fun table() { val table = buildBlock { table { column({ text("Key") }) { cell { text("key1") } cell { emphasis { text("key2") } } } column({ text("Value") }, alignment = Table.Alignment.CENTER) { cell { text("true") } cell { codeSpan("false") } } } } assertNodeEquals( Table( columns = listOf( Table.Column( alignment = Table.Alignment.NONE, header = Table.Cell(listOf(Text("Key"))), cells = listOf( Table.Cell(listOf(Text("key1"))), Table.Cell(listOf(Emphasis(listOf(Text("key2"))))), ), ), Table.Column( alignment = Table.Alignment.CENTER, header = Table.Cell(listOf(Text("Value"))), cells = listOf( Table.Cell(listOf(Text("true"))), Table.Cell(listOf(CodeSpan("false"))), ), ), ), ), table, ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/BibliographyCitationResolutionTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.bibliography.Bibliography import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.bibliography.style.BibliographyEntryLabelProviderStrategy import com.quarkdown.core.bibliography.style.BibliographyStyle import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.reference.BibliographyCitationResolverHook import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import kotlin.test.Test import kotlin.test.assertEquals private const val CITATION_KEY = "einstein" /** * Stub [BibliographyStyle] for tests that only need citation resolution, not formatting. */ private val stubStyle = object : BibliographyStyle { override val name = "stub" override val labelProvider = object : BibliographyEntryLabelProviderStrategy { override fun getCitationLabel(entries: List) = "" override fun getListLabel( entry: BibliographyEntry, index: Int, ) = "[${index + 1}]" } override fun contentOf(entry: BibliographyEntry) = emptyList() } /** * Tests for resolving bibliography citations to their bibliography entries. */ class BibliographyCitationResolutionTest { private val context = MutableContext(QuarkdownFlavor) private val bibliographyView = BibliographyView( bibliography = Bibliography( listOf("einstein", "latexcompanion", "knuthwebsite") .associateWith { BibliographyEntry(it) }, ), style = stubStyle, ) private val citation = BibliographyCitation(listOf(CITATION_KEY)) private fun traverse(root: Node) { ObservableAstIterator() .attach(BibliographyCitationResolverHook(context)) .traverse(root as NestableNode) } @Test fun `citation after bibliography`() { val root = buildBlock { root { +bibliographyView +citation } } traverse(root) val resolved = citation.getDefinition(context)?.first assertEquals(1, resolved?.size) assertEquals(bibliographyView.bibliography.entries[CITATION_KEY], resolved?.first()) } @Test fun `citation before bibliography`() { val root = buildBlock { root { +citation +bibliographyView } } traverse(root) val resolved = citation.getDefinition(context)?.first assertEquals(1, resolved?.size) assertEquals(bibliographyView.bibliography.entries[CITATION_KEY], resolved?.first()) } @Test fun `multi-key citation`() { val multiCitation = BibliographyCitation(listOf("einstein", "latexcompanion")) val root = buildBlock { root { +bibliographyView +multiCitation } } traverse(root) val resolved = multiCitation.getDefinition(context)?.first assertEquals(2, resolved?.size) assertEquals(bibliographyView.bibliography.entries["einstein"], resolved?.get(0)) assertEquals(bibliographyView.bibliography.entries["latexcompanion"], resolved?.get(1)) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/BlockParserTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.TaskListItemVariant import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.PlainTextNode import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.block.ImageFigure import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.size.cm import com.quarkdown.core.document.size.inch import com.quarkdown.core.document.size.mm import com.quarkdown.core.document.size.px import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.call.UncheckedFunctionCall import com.quarkdown.core.util.node.toPlainText import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue /** * Parsing tests. */ class BlockParserTest { /** * Tokenizes and parses a [source] code. * @param source source code * @param assertType if `true`, asserts each output node is of type [T] * @param flavor Markdown flavor to use * @param T type of the nodes to output * @return iterator of the parsed nodes */ private inline fun blocksIterator( source: CharSequence, assertType: Boolean = true, flavor: MarkdownFlavor = QuarkdownFlavor, ): Iterator { val context = MutableContext(flavor) context.attachMockPipeline() val lexer = flavor.lexerFactory.newBlockLexer(source) val parser = flavor.parserFactory.newParser(context) return nodesIterator(lexer, parser, assertType) } private val TextNode.rawText: String get() { (children.singleOrNull() as? PlainTextNode)?.let { return it.text } throw IllegalStateException("rawText requires a single PlainText node") } /** * @param node parent node * @param childIndex index of the text child * @return text content of the [childIndex]-th child */ private fun rawText( node: NestableNode, childIndex: Int = 0, ): String = (node.children[childIndex] as TextNode).rawText @Test fun paragraph() { val nodes = blocksIterator(readSource("/parsing/paragraph.md")) assertEquals("Paragraph 1", nodes.next().rawText) assertEquals("Paragraph 2", nodes.next().rawText) assertEquals("Paragraph 3", nodes.next().rawText) assertEquals("Paragraph 4\nwith lazy line", nodes.next().rawText) } @Test fun heading() { val nodes = blocksIterator(readSource("/parsing/heading.md"), assertType = false) with(nodes.next()) { assertEquals("Title", rawText) assertNull(customId) assertEquals(1, depth) assertTrue(canTrackLocation) assertTrue(canBreakPage) } with(nodes.next()) { assertEquals("Title", rawText) assertNull(customId) assertEquals(2, depth) } with(nodes.next()) { assertEquals("Title", rawText) assertNull(customId) assertEquals(3, depth) } with(nodes.next()) { assertEquals("Decorative title", rawText) assertNull(customId) assertEquals(1, depth) assertFalse(canTrackLocation) assertFalse(canBreakPage) } with(nodes.next()) { assertEquals("Decorative title", rawText) assertNull(customId) assertEquals(6, depth) assertFalse(canTrackLocation) assertFalse(canBreakPage) } with(nodes.next()) { assertTrue(text.isEmpty()) assertNull(customId) assertEquals(1, depth) } with(nodes.next()) { assertEquals("Title with closing sequence", rawText) assertNull(customId) assertEquals(2, depth) } with(nodes.next()) { assertEquals("Title with custom ID", rawText) assertEquals("custom-id", customId) assertEquals(1, depth) } with(nodes.next()) { assertEquals("Title with custom ID", rawText) assertEquals("id", customId) assertEquals(3, depth) } } @Test fun setextHeading() { val nodes = blocksIterator(readSource("/parsing/setextheading.md")) repeat(3) { with(nodes.next()) { assertEquals("Title 1", rawText) assertNull(customId) assertEquals(1, depth) } } repeat(3) { with(nodes.next()) { assertEquals("Title 2", rawText) assertNull(customId) assertEquals(2, depth) } } with(nodes.next()) { assertEquals("Title with ID", rawText) assertEquals("my-id", customId) assertEquals(1, depth) } } @Test fun blockCode() { val nodes = blocksIterator(readSource("/parsing/blockcode.md")) assertEquals("Code line 1\nCode line 2\n\nCode line 3", nodes.next().content) assertFalse(nodes.hasNext()) } @Test fun fencesCode() { val nodes = blocksIterator(readSource("/parsing/fencescode.md")) with(nodes.next()) { assertEquals("Code", content) assertNull(language) assertNull(caption) assertNull(referenceId) } with(nodes.next()) { assertEquals("Code", content) assertNull(language) } with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertNull(language) } with(nodes.next()) { assertEquals("Code line 1\n Code line 2", content) assertNull(language) } with(nodes.next()) { assertEquals("Code line 1\nCode line 2\n Code line 3\n Code line 4", content) assertNull(language) } with(nodes.next()) { assertEquals("Code", content) assertEquals("text", language) } with(nodes.next()) { assertEquals("Code", content) assertEquals("text", language) } with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertEquals("text", language) } with(nodes.next()) { assertEquals("Code line 1\n Code line 2", content) assertEquals("text", language) } with(nodes.next()) { assertEquals("let x;", content) assertEquals("ecmascript 6", language) assertNull(caption) assertNull(referenceId) } with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertEquals("text", language) assertEquals("The caption", caption) assertNull(referenceId) } repeat(2) { with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertEquals("text", language) assertEquals("custom-id", referenceId) } } with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertEquals("{#custom-id}", language) assertNull(referenceId) } with(nodes.next()) { assertEquals("Code line 1\nCode line 2", content) assertEquals("text", language) assertEquals("The caption", caption) assertEquals("custom-id", referenceId) } } @Test fun multilineMath() { val nodes = blocksIterator(readSource("/parsing/math_multiline.md"), assertType = false) repeat(3) { assertEquals("Math expression", nodes.next().expression) } assertEquals("Line 1\nLine 2", nodes.next().expression) with(nodes.next()) { assertEquals("Line 1\nLine 2", expression) assertEquals("custom-id", referenceId) } } @Test fun onelineMath() { val nodes = blocksIterator(readSource("/parsing/math_oneline.md"), assertType = false) repeat(2) { assertEquals("Math expression", nodes.next().expression) } assertEquals($$"Math $expression", nodes.next().expression) assertEquals("Math expression$", nodes.next().expression) with(nodes.next()) { assertEquals("Math expression", expression) assertEquals("custom-id", referenceId) } } @Test fun horizontalRule() { val nodes = blocksIterator(readSource("/parsing/hr.md"), assertType = false) assertEquals(7, nodes.asSequence().count()) } @Test fun pageBreak() { val nodes = blocksIterator(readSource("/parsing/pagebreak.md"), assertType = false) assertEquals(2, nodes.asSequence().count()) } @Test fun blockQuote() { val nodes = blocksIterator
(readSource("/parsing/blockquote.md"), assertType = false) assertEquals("Text", rawText(nodes.next())) assertEquals("Text", rawText(nodes.next())) assertEquals("Line 1\nLine 2", rawText(nodes.next())) with(nodes.next()) { assertIs(children[0]) assertEquals("Paragraph 1", rawText(this, childIndex = 0)) assertIs(children[1]) assertEquals("Paragraph 2", rawText(this, childIndex = 2)) } with(nodes.next()) { assertEquals("Text", rawText(this)) assertIs
(children[1]) assertEquals("Inner quote", rawText(children[1] as NestableNode)) } with(nodes.next()) { assertEquals("Text\nwith lazy line", rawText(this)) } with(nodes.next()) { assertEquals("Text", rawText(this)) assertIs
(children[1]) assertEquals("Inner text\nwith lazy\nlines", rawText(children[1] as NestableNode)) } repeat(3) { assertEquals("Text", rawText(nodes.next())) } with(nodes.next().children.first()) { assertIs(this) assertEquals(2, children.size) } repeat(2) { with(nodes.next()) { assertEquals("A note.", rawText(this)) assertEquals(BlockQuote.Type.NOTE, type) } } repeat(2) { with(nodes.next()) { assertEquals("This is a tip!", rawText(this)) assertIs(children[1]) assertEquals(BlockQuote.Type.TIP, type) } } repeat(2) { with(nodes.next()) { assertEquals("you should be\nmore careful.", rawText(this)) assertEquals(BlockQuote.Type.WARNING, type) } } with(nodes.next()) { assertEquals("Something: not a typed quote.", rawText(this)) assertNull(type) } with(nodes.next()) { val paragraph = children.first() as Paragraph assertIs(paragraph.children.first()) assertEquals("not a typed quote.", (paragraph.children[1] as Text).text.trimStart()) assertNull(type) } with(nodes.next()) { assertEquals("To be, or not to be, that is the question.", rawText(this)) assertNodeEquals(Text("William Shakespeare, Hamlet"), attribution!!.single()) } with(nodes.next()) { assertEquals("Shopping list", rawText(this)) assertIs(children[1]) assertNull(attribution) } with(nodes.next()) { assertEquals(1, children.size) children.first().let { inner -> assertIs
(inner) assertEquals("You miss 100% of the shots you don’t take.", inner.children.toPlainText()) assertNodeEquals(Text("Wayne Gretzky"), inner.attribution!!.single()) } assertNodeEquals(Emphasis(listOf(Text("Michael Scott"))), attribution!!.single()) } with(nodes.next()) { assertEquals("Try Quarkdown.", rawText(this)) assertNodeEquals(Text("iamgio"), attribution!!.single()) assertEquals(BlockQuote.Type.TIP, type) } assertFalse(nodes.hasNext()) } @Test fun linkDefinition() { val nodes = blocksIterator(readSource("/parsing/linkdefinition.md")) with(nodes.next()) { assertEquals("label", rawText) assertEquals("https://google.com", url) assertEquals(null, title) } with(nodes.next()) { assertEquals("label", rawText) assertEquals("url", url) assertEquals(null, title) } with(nodes.next()) { assertEquals("label", rawText) assertEquals("/url", url) assertEquals(null, title) } repeat(4) { with(nodes.next()) { assertEquals("label", rawText) assertEquals("https://google.com", url) assertEquals("Title", title) } } with(nodes.next()) { assertEquals("label", rawText) assertEquals("https://google.com", url) assertEquals("Multiline\ntitle", title) } with(nodes.next()) { assertEquals("label", rawText) assertEquals("https://google.com", url) assertEquals("Line 1\nLine 2\nLine 3", title) } with(nodes.next()) { assertEquals("label", rawText) assertEquals("/url", url) assertEquals("Title", title) } } @Test fun footnoteDefinition() { val nodes = blocksIterator(readSource("/parsing/footnotedefinition.md"), assertType = false) with(nodes.next()) { assertEquals("Footnote on one line.", text.toPlainText()) assertEquals("1", label) } with(nodes.next()) { assertEquals("Footnote on one line.", text.toPlainText()) assertEquals("*Footnote*", label) } with(nodes.next()) { assertEquals("Footnote\non two lines.", text.toPlainText()) assertEquals("2", label) } with(nodes.next()) { assertEquals("Footnote\n on indented\n lines.", text.toPlainText()) assertEquals("3", label) } repeat(2) { with(nodes.next()) { assertEquals("Interrupted footnote", text.toPlainText()) assertEquals("int", label) } } } @Test fun table() { val nodes = blocksIterator
(readSource("/parsing/table.md")) with(nodes.next()) { assertNull(caption) assertNull(referenceId) val columns = columns.iterator() with(columns.next()) { assertEquals(Table.Alignment.NONE, alignment) assertNodeEquals(Text("foo"), header.text.first()) assertEquals(2, cells.size) assertNodeEquals(Text("abc"), cells[0].text.first()) assertNodeEquals(Text("ghi"), cells[1].text.first()) } with(columns.next()) { assertEquals(Table.Alignment.NONE, alignment) assertNodeEquals(Text("bar"), header.text.first()) assertEquals(2, cells.size) assertNodeEquals(Text("def"), cells[0].text.first()) assertNodeEquals(Text("jkl"), cells[1].text.first()) } } with(nodes.next().columns.iterator()) { with(next()) { assertEquals(Table.Alignment.CENTER, alignment) assertNodeEquals(Text("abc"), header.text.first()) assertEquals(1, cells.size) assertNodeEquals(Text("bar"), cells.first().text.first()) } with(next()) { assertEquals(Table.Alignment.RIGHT, alignment) assertNodeEquals(Text("defghi"), header.text.first()) assertEquals(1, cells.size) assertNodeEquals(Text("baz"), cells.first().text.first()) } } with(nodes.next().columns.iterator()) { with(next()) { assertEquals(Table.Alignment.NONE, alignment) assertEquals(2, cells.size) } assertFalse(hasNext()) } with(nodes.next().columns.iterator()) { with(next()) { assertEquals(Table.Alignment.NONE, alignment) assertNodeEquals(Text("abc"), header.text.first()) assertEquals(2, cells.size) assertNodeEquals(Text("bar"), cells[0].text.first()) assertNodeEquals(Text("bar"), cells[1].text.first()) } with(next()) { assertEquals(Table.Alignment.LEFT, alignment) assertNodeEquals(Text("def"), header.text.first()) assertEquals(2, cells.size) assertTrue(cells[0].text.isEmpty()) assertNodeEquals(Text("baz"), cells[1].text.first()) } assertFalse(hasNext()) } with(nodes.next().columns.iterator()) { with(next()) { assertEquals(Table.Alignment.LEFT, alignment) assertTrue(header.text.isEmpty()) assertEquals(2, cells.size) assertNodeEquals(Strong(listOf(Text("C"))), cells[0].text.first()) assertNodeEquals(Strong(listOf(Text("D"))), cells[1].text.first()) } with(next()) { assertEquals(Table.Alignment.NONE, alignment) assertNodeEquals(Text("A"), header.text.first()) assertEquals(2, cells.size) assertNodeEquals(Text("AC"), cells[0].text.first()) assertNodeEquals(Text("AD"), cells[1].text.first()) } with(next()) { assertEquals(Table.Alignment.RIGHT, alignment) assertNodeEquals(Text("B"), header.text.first()) assertEquals(2, cells.size) assertNodeEquals(Text("BC"), cells[0].text.first()) assertNodeEquals(Text("BD"), cells[1].text.first()) } assertFalse(hasNext()) } with(nodes.next()) { // this is a 3-column table with only empty header and cells. assertNull(caption) assertNull(referenceId) repeat(3) { val column = columns.iterator().next() assertEquals(Table.Alignment.NONE, column.alignment) assertTrue(column.header.text.isEmpty()) assertTrue( column.cells .first() .text .isEmpty(), ) } } repeat(2) { with(nodes.next()) { assertEquals("Table caption", caption) assertNull(referenceId) val columns = columns.iterator() with(columns.next()) { assertEquals(Table.Alignment.NONE, alignment) assertEquals(2, cells.size) assertNodeEquals(Text("G H I"), cells[0].text.first()) assertNodeEquals(Text("M N O"), cells[1].text.first()) } with(columns.next()) { assertEquals(Table.Alignment.NONE, alignment) assertEquals(2, cells.size) assertNodeEquals(Text("J K L"), cells[0].text.first()) assertNodeEquals(Text("P Q R"), cells[1].text.first()) } assertFalse(columns.hasNext()) } } with(nodes.next()) { assertNull(caption) assertEquals("custom-id", referenceId) } with(nodes.next()) { assertEquals("Table caption", caption) assertEquals("custom-id", referenceId) } assertFalse(nodes.hasNext()) } // This is shared by both unordered and ordered list tests. private inline fun list(source: CharSequence) { val nodes = blocksIterator(source, assertType = false) // First list with(nodes.next()) { assertIs(this) assertFalse(isLoose) if (this is OrderedList) { assertEquals(1, startIndex) } val items = children.iterator() with(items.next()) { assertIs(this) assertEquals("A", rawText(this)) assertEquals(0, this.variants.size) } with(items.next()) { assertIs(this) assertEquals("B", rawText(this)) } with(items.next()) { assertIs(this) assertEquals("C", rawText(this)) } } // List after two blank lines with(nodes.next()) { assertIs(this) assertFalse(isLoose) val items = children.iterator() with(items.next()) { assertIs(this) assertEquals("A", rawText(this)) } with(items.next()) { assertIs(this) assertEquals("B", rawText(this)) } } // Different list for different bullet character with(nodes.next()) { assertIs(this) assertFalse(isLoose) with(children.first()) { assertIs(this) assertEquals("C", rawText(this)) } } // List after two blank lines with(nodes.next()) { assertIs(this) assertTrue(isLoose) val items = children.iterator() with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("A", rawText(this, childIndex = 0)) assertIs(children[1]) assertIs(children[2]) assertEquals("Some paragraph", rawText(this, childIndex = 2)) assertEquals(0, variants.size) } assertIs(items.next()) // Nested list with(items.next()) { // First list item assertIs(this) assertEquals("B", rawText(this)) assertIs(children[0]) with(children[1]) { assertIs(this) assertEquals(1, children.size) with(children[0]) { // Second list item assertIs(this) assertEquals("Nested 1", rawText(this)) assertIs(children[0]) assertEquals(0, variants.size) with(children[1]) { assertIs(this) assertTrue(isLoose) with(children[0]) { // Third list item assertIs(this) assertIs(children[0]) assertEquals("Nested A", rawText(this)) assertIs(children[1]) assertIs(children[2]) assertEquals("Some paragraph", rawText(this, childIndex = 2)) } assertIs(children[1]) with(children[2]) { assertIs(this) assertIs(children[0]) assertEquals("Nested B", rawText(this)) } } } } } assertIs(items.next()) with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("C", rawText(this, childIndex = 0)) assertIs(children[1]) assertIs
(children[2]) assertEquals("Some quote", rawText(children[2] as NestableNode, childIndex = 0)) } assertIs(items.next()) with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("D", rawText(this, childIndex = 0)) assertIs(children[1]) assertIs(children[2]) assertEquals("Some paragraph", rawText(this, childIndex = 2)) with(children[3]) { assertIs(this) with(children[0]) { assertIs(this) assertIs(children[0]) assertEquals("E", rawText(this)) assertIs(children[1]) } } } assertIs(items.next()) with(items.next()) { assertIs(this) with(children[0]) { assertIs(this) with(children[0]) { assertIs(this) assertIs(children[0]) assertEquals("E", rawText(this)) } } } } // List after paragraph with(nodes.next()) { assertIs(this) assertTrue(isLoose) val items = children.iterator() with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("Another list\nwith lazy line", rawText(this)) } assertIs(items.next()) with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("B", rawText(this, childIndex = 0)) assertIs(children[1]) assertIs(children[2]) assertEquals("Some paragraph\nwith lazy line", rawText(this, childIndex = 2)) } assertIs(items.next()) with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("Heading", rawText(this)) } with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("C", rawText(this)) } with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("Heading", rawText(this, childIndex = 0)) assertIs(children[1]) assertEquals("Some paragraph", rawText(this, childIndex = 1)) } } // List after heading with(nodes.next()) { assertIs(this) assertFalse(isLoose) if (this is OrderedList) { assertEquals(9, startIndex) } val items = children.iterator() with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("A", rawText(this)) assertEquals(0, variants.size) } with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("B", rawText(this)) } } // List after horizontal rule with(nodes.next()) { assertIs(this) assertFalse(isLoose) with(children.iterator().next()) { assertIs(this) assertIs(children[0]) assertEquals("A", rawText(this)) } } // List after blockquote with(nodes.next()) { assertIs(this) assertFalse(isLoose) val items = children.iterator() with(items.next()) { assertIs(this) assertIs
(children[0]) } with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("A", rawText(this)) } } // List after fence code with(nodes.next()) { assertIs(this) assertFalse(isLoose) if (this is OrderedList) { assertEquals(3, startIndex) } val items = children.iterator() with(items.next()) { assertIs(this) assertIs(children[0]) assertEquals("A", rawText(this)) } with(items.next()) { assertIs(this) with(children[0]) { assertIs(this) assertEquals("Some multiline\ncode", this.content) } } } // List after heading with(nodes.next()) { assertIs(this) assertFalse(isLoose) val items = children.iterator() repeat(2) { with(items.next()) { assertIs(this) assertEquals(1, variants.size) assertTrue((variants.first() as TaskListItemVariant).isChecked) assertIs(children[0]) assertEquals("Checked", rawText(this)) } } with(items.next()) { assertIs(this) assertEquals(1, variants.size) assertFalse((variants.first() as TaskListItemVariant).isChecked) assertIs(children[0]) assertEquals("Unchecked", rawText(this)) } } // List after heading with(nodes.next()) { assertIs(this) assertFalse(isLoose) assertEquals(2, children.size) with((children[1] as ListItem).children[1]) { assertIs(this) if (this is OrderedList) { assertEquals(1, startIndex) } with(children[0]) { assertIs(this) assertEquals(1, variants.size) assertTrue((variants.first() as TaskListItemVariant).isChecked) assertIs(children[0]) } } } assertFalse(nodes.hasNext()) } @Test fun unorderedList() { list(readSource("/parsing/unorderedlist.md")) } @Test fun orderedList() { list(readSource("/parsing/orderedlist.md")) } @Test fun figure() { val nodes = blocksIterator(readSource("/parsing/figure.md")) with(nodes.next()) { assertNodeEquals( buildInline { image("/url") { text("Label") } }.first(), child, ) assertNull(caption) assertNull(referenceId) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", "Title") { text("Label") } }.first(), child, ) assertEquals("Title", caption) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, 150.px, 100.px) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, 150.px, null) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, null, 100.px) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, 1.0.cm, 2.1.inch) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, 100.px, 50.0.mm) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertNodeEquals( buildInline { image("/url", null, null, 10.px) { text("Label") } }.first(), child, ) } with(nodes.next()) { assertEquals("custom-id", referenceId) } } @Test fun functionCall() { val nodes = blocksIterator(readSource("/parsing/functioncall.md"), assertType = false) with(nodes.next()) { assertEquals("function", name) assertEquals(0, arguments.size) assertEquals(0..9, sourceRange) } with(nodes.next()) { assertEquals("function", name) assertEquals(2, arguments.size) assertEquals("arg1", arguments[0].value.unwrappedValue) assertEquals("arg2", arguments[1].value.unwrappedValue) assertFalse(arguments[0].isBody) assertFalse(arguments[1].isBody) assertEquals(11..34, sourceRange) } with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertEquals("arg1}", arguments[0].value.unwrappedValue) } // `.function \{arg1}` is treated as a paragraph (inline function call + text), // not as a block function call, because the line has content after the call. // Tested in `inlineFunctionCallInParagraph`. with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertTrue(arguments.first().isBody) assertEquals( "body content", arguments.first().value.unwrappedValue, ) } with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertTrue(arguments.first().isBody) assertEquals( "body content", arguments.first().value.unwrappedValue, ) } with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertTrue(arguments.first().isBody) assertEquals( "body content\nbody **content**", arguments.first().value.unwrappedValue, ) assertEquals(130..176, sourceRange) } with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertTrue(arguments.first().isBody) assertEquals( " body content\nbody content", arguments.first().value.unwrappedValue, ) } with(nodes.next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertTrue(arguments.first().isBody) assertEquals( "body content\n\nbody content\n\nbody content", arguments.first().value.unwrappedValue, ) } with(nodes.next()) { assertEquals("function", name) assertEquals(4, arguments.size) val args = arguments.iterator() with(args.next()) { assertEquals("arg1", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertEquals("arg2", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertEquals("arg3", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertTrue(this.isBody) assertEquals( "body content\n\n body content\n\nbody content", value.unwrappedValue, ) } } with(nodes.next()) { assertEquals("function", name) assertEquals(2, arguments.size) val args = arguments.iterator() with(args.next()) { assertEquals("{{arg1}}", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertEquals("{arg2}", value.unwrappedValue) assertFalse(this.isBody) } } // `.function {arg{1}} {arg2}}` has a trailing `}` after the parsed arguments, // so it is treated as a paragraph (inline function call + trailing text), not a block call. // Tested in `inlineFunctionCallInParagraph`. with(nodes.next()) { assertEquals("function", name) assertEquals(3, arguments.size) val args = arguments.iterator() with(args.next()) { assertEquals("arg{1} arg", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertEquals("{ arg2 }", value.unwrappedValue) assertFalse(this.isBody) } with(args.next()) { assertTrue(this.isBody) assertEquals( "body content", value.unwrappedValue, ) } } } /** * Verifies that function calls followed by non-whitespace content on the same line * are treated as paragraphs containing inline function calls, not as block function calls. */ @Test fun inlineFunctionCallInParagraph() { // `.function \{arg1}`: the escaped brace text follows the call on the same line. with(blocksIterator(".function \\{arg1}").next()) { val children = children.iterator() assertIs(children.next()) assertEquals(" ", assertIs(children.next()).text) assertEquals("{", assertIs(children.next()).text) assertEquals("arg1}", assertIs(children.next()).text) assertFalse(children.hasNext()) } // `.function {arg{1}} {arg2}}`: trailing `}` after the parsed arguments. with(blocksIterator(".function {arg{1}} {arg2}}").next()) { val children = children.iterator() assertIs(children.next()) assertEquals("}", assertIs(children.next()).text) assertFalse(children.hasNext()) } } /** * Verifies that wrapped function calls (`{.func {args}}`) are parsed as inline function calls * within paragraphs, with the wrapping braces stripped from the output. */ @Test fun wrappedInlineFunctionCall() { // Standalone wrapped call on a line is treated as a block-level function call. with(blocksIterator("{.function {x}}").next()) { assertEquals("function", name) assertEquals(1, arguments.size) assertEquals("x", arguments[0].value.unwrappedValue) } // Wrapped call between text: parsed as a paragraph containing an inline function call. with(blocksIterator("hello {.function {x}} world").next()) { val children = children.iterator() assertEquals("hello ", assertIs(children.next()).text) assertIs(children.next()) assertEquals(" world", assertIs(children.next()).text) assertFalse(children.hasNext()) } // Tight wrapped call (no spacing around braces). with(blocksIterator("hello{.function {x}}world").next()) { val children = children.iterator() assertEquals("hello", assertIs(children.next()).text) assertIs(children.next()) assertEquals("world", assertIs(children.next()).text) assertFalse(children.hasNext()) } } @Test fun chainedFunctionCall() { val nodes = blocksIterator(readSource("/parsing/functioncall-chain.md")) // .foo::bar {x} with(nodes.next()) { assertEquals("bar", name) assertEquals(2, arguments.size) arguments.first().expression.let { assertIs>(it) assertEquals("foo", it.name) } assertEquals("x", arguments[1].value.unwrappedValue) } // .foo {x}::bar name:{y} with(nodes.next()) { assertEquals("bar", name) assertEquals(2, arguments.size) assertEquals("foo", (arguments.first().expression as UncheckedFunctionCall<*>).name) assertEquals("y", arguments[1].value.unwrappedValue) assertEquals("name", arguments[1].name) } // .foo {x}::bar {y}::baz {z} with(nodes.next()) { assertEquals("baz", name) assertEquals(2, arguments.size) assertEquals("z", arguments[1].value.unwrappedValue) assertEquals("bar", (arguments.first().expression as UncheckedFunctionCall<*>).name) } assertFalse(nodes.hasNext()) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/ChildContextIsolationTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.SharedContext import com.quarkdown.core.context.SubdocumentContext import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertSame /** * Tests for property isolation and sharing behavior in [SharedContext] and [SubdocumentContext]. */ class ChildContextIsolationTest { // SharedContext isolation tests @Test fun `SharedContext shares documentInfo with parent`() { val parent = MutableContext() val child = SharedContext(parent) val newInfo = DocumentInfo(name = "Test") child.documentInfo = newInfo assertSame(newInfo, parent.documentInfo) assertSame(parent.documentInfo, child.documentInfo) } @Test fun `SharedContext shares libraries with parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent.libraries, child.libraries) } @Test fun `SharedContext shares options with parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent.options, child.options) } @Test fun `SharedContext shares attributes with parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent.attributes, child.attributes) } @Test fun `SharedContext shares localizationTables with parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent.localizationTables, child.localizationTables) } @Test fun `SharedContext shares mediaStorage with parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent.mediaStorage, child.mediaStorage) } // SubdocumentContext isolation tests private fun createSubdocumentContext(): Triple { val parent = MutableContext() val subdoc = Subdocument.Resource(name = "sub", path = "/test/sub.qd", content = "") val child = SubdocumentContext(parent, subdoc) return Triple(parent, subdoc, child) } @Test fun `SubdocumentContext has isolated documentInfo`() { val (parent, _, child) = createSubdocumentContext() // Initially they have equal values (copied from parent) assertEquals(parent.documentInfo, child.documentInfo) // Modify child's documentInfo val childInfo = DocumentInfo(name = "Child Doc") child.documentInfo = childInfo // Parent should be unchanged assertNotEquals(parent.documentInfo, child.documentInfo) assertEquals(childInfo, child.documentInfo) } @Test fun `SubdocumentContext shares options with parent`() { val (parent, _, child) = createSubdocumentContext() assertSame(parent.options, child.options) } @Test fun `SubdocumentContext shares loadableLibraries with parent`() { val (parent, _, child) = createSubdocumentContext() assertSame(parent.loadableLibraries, child.loadableLibraries) } @Test fun `SubdocumentContext shares localizationTables with parent`() { val (parent, _, child) = createSubdocumentContext() assertSame(parent.localizationTables, child.localizationTables) } @Test fun `SubdocumentContext shares sharedSubdocumentsData with parent`() { val (parent, _, child) = createSubdocumentContext() assertSame(parent.sharedSubdocumentsData, child.sharedSubdocumentsData) } @Test fun `SubdocumentContext has own subdocument`() { val (parent, subdoc, child) = createSubdocumentContext() assertSame(Subdocument.Root, parent.subdocument) assertSame(subdoc, child.subdocument) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/ChildContextTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.SharedContext import kotlin.test.Test import kotlin.test.assertNull import kotlin.test.assertSame /** * Tests for [com.quarkdown.core.context.ChildContext]. */ class ChildContextTest { @Test fun `has correct parent`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent, child.parent) } @Test fun `root returns parent when single level`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent, child.root) } @Test fun `root returns topmost context in deep hierarchy`() { val root = MutableContext() val level1 = SharedContext(root) val level2 = SharedContext(level1) val level3 = SharedContext(level2) assertSame(root, level1.root) assertSame(root, level2.root) assertSame(root, level3.root) } @Test fun `lastParentOrNull returns null when no context matches`() { val parent = MutableContext() val child = SharedContext(parent) assertNull(child.lastParentOrNull { false }) } @Test fun `lastParentOrNull returns this when only child matches`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(child, child.lastParentOrNull { it is SharedContext }) } @Test fun `lastParentOrNull returns parent when only parent matches`() { val parent = MutableContext() val child = SharedContext(parent) assertSame(parent, child.lastParentOrNull { it !is SharedContext }) } @Test fun `lastParentOrNull returns topmost matching context in hierarchy`() { val root = MutableContext() val level1 = SharedContext(root) val level2 = SharedContext(level1) val level3 = SharedContext(level2) // All SharedContexts match, so the last (topmost) is level1 assertSame(level1, level3.lastParentOrNull { it is SharedContext }) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/CrossReferenceResolutionTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.block.ImageFigure import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook import com.quarkdown.core.context.hooks.location.LocationAwarenessHook import com.quarkdown.core.context.hooks.reference.CrossReferenceResolverHook import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull private const val ID = "ref-id" /** * Tests for resolving cross-references to their definitions. */ class CrossReferenceResolutionTest { private val context = MutableContext(QuarkdownFlavor) private fun traverse(root: Node) { ObservableAstIterator() .attach(LocationAwarenessHook(context)) .attach(LocationAwareLabelStorerHook(context)) .attach(CrossReferenceResolverHook(context)) .traverse(root as NestableNode) } @Test fun `reference after definition (heading)`() { val definition = Heading(depth = 2, text = buildInline { text("Heading") }, customId = ID) val reference = CrossReference(ID) val root = buildBlock { root { +definition +reference } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } @Test fun `reference before definition (heading)`() { val definition = Heading(depth = 6, text = buildInline { text("Heading") }, customId = ID) val reference = CrossReference(ID) val root = buildBlock { root { +reference +definition } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } @Test fun `invalid reference`() { val reference = CrossReference(ID) val root = buildBlock { root { +reference } } traverse(root) assertNull(reference.getDefinition(context)) } @Test fun `multiple references to the same definition`() { val definition = Heading(depth = 3, text = buildInline { text("Heading") }, customId = ID) val reference1 = CrossReference(ID) val reference2 = CrossReference(ID) val root = buildBlock { root { +reference1 +definition +reference2 } } traverse(root) assertEquals(definition, reference1.getDefinition(context)) assertEquals(definition, reference2.getDefinition(context)) } @Test fun `multiple definitions with the same ID - first one wins`() { val definition1 = Heading(depth = 4, text = buildInline { text("Heading 1") }, customId = ID) val definition2 = Heading(depth = 5, text = buildInline { text("Heading 2") }, customId = ID) val reference = CrossReference(ID) val root = buildBlock { root { +definition1 +reference +definition2 } } traverse(root) assertEquals(definition1, reference.getDefinition(context)) } @Test fun `mutual references (headings)`() { val definition1 = Heading(depth = 2, text = buildInline { text("Heading 1") }, customId = "id-1") val definition2 = Heading(depth = 3, text = buildInline { text("Heading 2") }, customId = "id-2") val reference1 = CrossReference("id-2") val reference2 = CrossReference("id-1") val root = buildBlock { root { +definition1 +reference1 +definition2 +reference2 } } traverse(root) assertEquals(definition2, reference1.getDefinition(context)) assertEquals(definition1, reference2.getDefinition(context)) } @Test fun `reference before definition (figures)`() { val definition = buildBlock { figure { image("image.png", referenceId = ID) { text("An image") } } } as ImageFigure val reference = CrossReference(ID) val root = buildBlock { root { +reference +definition } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } @Test fun `reference after definition (tables)`() { val definition = buildBlock { table(referenceId = ID) { column(header = { text("Header") }) { cell { text("Cell 1") } cell { text("Cell 2") } } } } as Table val reference = CrossReference(ID) val root = buildBlock { root { +definition +reference } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } @Test fun `reference before definition (code blocks)`() { val definition = Code( content = "println(\"Hello, World!\")", language = "kotlin", referenceId = ID, ) val reference = CrossReference(ID) val root = buildBlock { root { +reference +definition } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } @Test fun `reference before definition (math)`() { val definition = Math("E = mc^2", referenceId = ID) val reference = CrossReference(ID) val root = buildBlock { root { +reference +definition } } traverse(root) assertEquals(definition, reference.getDefinition(context)) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/CslBibliographyStyleTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.bibliography.style.csl.CslBibliographyStyle import com.quarkdown.core.util.node.toPlainText import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for CSL-based bibliography styles via [CslBibliographyStyle]. */ class CslBibliographyStyleTest { private fun bibResource(name: String) = javaClass.getResourceAsStream("/bib/$name")!! private fun cslStyle(styleName: String): CslBibliographyStyle = CslBibliographyStyle.from(styleName, bibResource("bibliography.bib"), "bibliography.bib") @Test fun `csl apa, article citation label`() { val style = cslStyle("apa") val entry = style.bibliography.entries["einstein"]!! val label = style.labelProvider.getCitationLabel(listOf(entry)) assertTrue("Einstein" in label, "APA citation label should contain author name, got: $label") assertTrue("1905" in label, "APA citation label should contain year, got: $label") } @Test fun `csl apa, article content contains formatted nodes`() { val style = cslStyle("apa") val entry = style.bibliography.entries["einstein"]!! val content = style.contentOf(entry) val plainText = content.toPlainText() // APA article: Author (Year). Title. *Journal*, *Volume*(Issue), Pages. DOI assertTrue("Einstein" in plainText, "Should contain author: $plainText") assertTrue("1905" in plainText, "Should contain year: $plainText") assertTrue("Annalen" in plainText, "Should contain journal: $plainText") // Journal name should be emphasized (italic). val hasEmphasizedJournal = content.any { node -> node is Emphasis && node.children.toPlainText().contains("Annalen") } assertTrue(hasEmphasizedJournal, "Journal name should be emphasized in APA: $content") // DOI should be a link. val hasDoiLink = content.any { node -> node is Link && "doi" in node.url.lowercase() } assertTrue(hasDoiLink, "DOI should be rendered as a link in APA: $content") } @Test fun `csl apa, book content`() { val style = cslStyle("apa") val entry = style.bibliography.entries["latexcompanion"]!! val content = style.contentOf(entry) val plainText = content.toPlainText() assertTrue("Goossens" in plainText, "Should contain author: $plainText") assertTrue("LaTeX Companion" in plainText || "latex companion" in plainText.lowercase(), "Should contain title: $plainText") // Book title should be emphasized in APA. val hasEmphasizedTitle = content.any { node -> node is Emphasis && node.children .toPlainText() .lowercase() .contains("latex companion") } assertTrue(hasEmphasizedTitle, "Book title should be emphasized in APA: $content") } @Test fun `csl apa, misc content`() { val style = cslStyle("apa") val entry = style.bibliography.entries["knuthwebsite"]!! val content = style.contentOf(entry) val plainText = content.toPlainText() assertTrue("Knuth" in plainText, "Should contain author: $plainText") } @Test fun `csl ieee, article citation label`() { val style = cslStyle("ieee") val entry = style.bibliography.entries["einstein"]!! val label = style.labelProvider.getCitationLabel(listOf(entry)) assertTrue("[" in label && "]" in label, "IEEE citation label should be bracketed, got: $label") } @Test fun `csl ieee, article content`() { val style = cslStyle("ieee") val entry = style.bibliography.entries["einstein"]!! val content = style.contentOf(entry) val plainText = content.toPlainText() assertTrue("Einstein" in plainText, "Should contain author: $plainText") assertTrue("1905" in plainText, "Should contain year: $plainText") } @Test fun `csl style name`() { val style = cslStyle("apa") assertEquals("apa", style.name) } @Test fun `csl ieee, list label is numbered`() { val style = cslStyle("ieee") val entry = style.bibliography.entries["einstein"]!! val label = style.labelProvider.getListLabel(entry, 0) assertTrue("[" in label && "]" in label, "IEEE list label should be numbered, got: $label") } @Test fun `csl apa, list label is empty`() { val style = cslStyle("apa") val entry = style.bibliography.entries["einstein"]!! assertEquals("", style.labelProvider.getListLabel(entry, 0)) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/DocumentLayoutInfoTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.document.layout.DocumentLayoutInfo import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.page.PageFormatSelector import com.quarkdown.core.document.layout.page.PageSide import com.quarkdown.core.document.size.Size import com.quarkdown.core.function.value.data.Range import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue private val WIDTH_A4 = Size(210.0, Size.Unit.MILLIMETERS) private val HEIGHT_A4 = Size(297.0, Size.Unit.MILLIMETERS) private val HEIGHT_SMALL = Size(6.0, Size.Unit.CENTIMETERS) private val DEFAULT_FORMAT = PageFormatInfo( pageWidth = WIDTH_A4, pageHeight = HEIGHT_A4, ) class DocumentLayoutInfoTest { @Test fun `no formats and no default returns empty`() { val layout = DocumentLayoutInfo() assertTrue(layout.getPageFormatsWithDefault(null).isEmpty()) } @Test fun `no formats with default returns default`() { val layout = DocumentLayoutInfo() val result = layout.getPageFormatsWithDefault(DEFAULT_FORMAT) assertEquals(1, result.size) assertEquals(WIDTH_A4, result.single().pageWidth) assertEquals(HEIGHT_A4, result.single().pageHeight) } @Test fun `user format without default is returned as is`() { val userFormat = PageFormatInfo(pageHeight = HEIGHT_SMALL) val layout = DocumentLayoutInfo(pageFormats = listOf(userFormat)) val result = layout.getPageFormatsWithDefault(null) assertEquals(1, result.size) assertNull(result.single().pageWidth) assertEquals(HEIGHT_SMALL, result.single().pageHeight) } @Test fun `user format merges with default, user fields take priority`() { val userFormat = PageFormatInfo(pageHeight = HEIGHT_SMALL) val layout = DocumentLayoutInfo(pageFormats = listOf(userFormat)) val result = layout.getPageFormatsWithDefault(DEFAULT_FORMAT) assertEquals(1, result.size) // Width inherited from default, height overridden by user. assertEquals(WIDTH_A4, result.single().pageWidth) assertEquals(HEIGHT_SMALL, result.single().pageHeight) } @Test fun `multiple user formats with same side are merged`() { val first = PageFormatInfo(pageWidth = WIDTH_A4) val second = PageFormatInfo(pageHeight = HEIGHT_SMALL, columnCount = 2) val layout = DocumentLayoutInfo(pageFormats = listOf(first, second)) val result = layout.getPageFormatsWithDefault(null) assertEquals(1, result.size) assertEquals(WIDTH_A4, result.single().pageWidth) assertEquals(HEIGHT_SMALL, result.single().pageHeight) assertEquals(2, result.single().columnCount) } @Test fun `side-specific formats are separate from global`() { val global = PageFormatInfo(pageWidth = WIDTH_A4, pageHeight = HEIGHT_A4) val left = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT), pageWidth = WIDTH_A4) val layout = DocumentLayoutInfo(pageFormats = listOf(global, left)) val result = layout.getPageFormatsWithDefault(null) assertEquals(2, result.size) val globalResult = result.first { it.selector == null } assertEquals(WIDTH_A4, globalResult.pageWidth) assertEquals(HEIGHT_A4, globalResult.pageHeight) val leftResult = result.first { it.selector?.side == PageSide.LEFT } assertEquals(WIDTH_A4, leftResult.pageWidth) assertNull(leftResult.pageHeight) } @Test fun `side-specific formats with same side are merged`() { val leftMargin = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT), pageWidth = WIDTH_A4) val leftColumns = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT), columnCount = 3) val layout = DocumentLayoutInfo(pageFormats = listOf(leftMargin, leftColumns)) val result = layout.getPageFormatsWithDefault(null) assertEquals(1, result.size) assertEquals(PageSide.LEFT, result.single().selector?.side) assertEquals(WIDTH_A4, result.single().pageWidth) assertEquals(3, result.single().columnCount) } @Test fun `default, global, and side-specific formats coexist`() { val userGlobal = PageFormatInfo(pageHeight = HEIGHT_SMALL) val left = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT), columnCount = 2) val right = PageFormatInfo(selector = PageFormatSelector(side = PageSide.RIGHT), columnCount = 3) val layout = DocumentLayoutInfo(pageFormats = listOf(userGlobal, left, right)) val result = layout.getPageFormatsWithDefault(DEFAULT_FORMAT) assertEquals(3, result.size) val globalResult = result.first { it.selector == null } assertEquals(WIDTH_A4, globalResult.pageWidth) assertEquals(HEIGHT_SMALL, globalResult.pageHeight) val leftResult = result.first { it.selector?.side == PageSide.LEFT } assertEquals(2, leftResult.columnCount) val rightResult = result.first { it.selector?.side == PageSide.RIGHT } assertEquals(3, rightResult.columnCount) } @Test fun `range-only format stays separate from global`() { val global = PageFormatInfo(pageWidth = WIDTH_A4) val ranged = PageFormatInfo(selector = PageFormatSelector(range = Range(2, 5)), columnCount = 2) val layout = DocumentLayoutInfo(pageFormats = listOf(global, ranged)) val result = layout.getPageFormatsWithDefault(null) assertEquals(2, result.size) val globalResult = result.first { it.selector == null } assertEquals(WIDTH_A4, globalResult.pageWidth) val rangedResult = result.first { it.selector?.range != null } assertEquals(2, rangedResult.columnCount) } @Test fun `formats with same selector merge`() { val first = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT, range = Range(1, 3)), pageWidth = WIDTH_A4) val second = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT, range = Range(1, 3)), columnCount = 2) val layout = DocumentLayoutInfo(pageFormats = listOf(first, second)) val result = layout.getPageFormatsWithDefault(null) assertEquals(1, result.size) assertEquals(WIDTH_A4, result.single().pageWidth) assertEquals(2, result.single().columnCount) } @Test fun `formats with same side but different range stay separate`() { val first = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT, range = Range(1, 3)), pageWidth = WIDTH_A4) val second = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT, range = Range(4, 6)), columnCount = 2) val layout = DocumentLayoutInfo(pageFormats = listOf(first, second)) val result = layout.getPageFormatsWithDefault(null) assertEquals(2, result.size) } @Test fun `default, global, side, and range formats coexist`() { val userGlobal = PageFormatInfo(pageHeight = HEIGHT_SMALL) val left = PageFormatInfo(selector = PageFormatSelector(side = PageSide.LEFT), columnCount = 2) val ranged = PageFormatInfo(selector = PageFormatSelector(range = Range(1, 3)), columnCount = 3) val layout = DocumentLayoutInfo(pageFormats = listOf(userGlobal, left, ranged)) val result = layout.getPageFormatsWithDefault(DEFAULT_FORMAT) assertEquals(3, result.size) val globalResult = result.first { it.selector == null } assertEquals(WIDTH_A4, globalResult.pageWidth) assertEquals(HEIGHT_SMALL, globalResult.pageHeight) val leftResult = result.first { it.selector?.side == PageSide.LEFT } assertEquals(2, leftResult.columnCount) val rangedResult = result.first { it.selector?.range != null } assertEquals(3, rangedResult.columnCount) } @Test fun `later user format overrides earlier for same field`() { val first = PageFormatInfo(pageHeight = HEIGHT_A4, columnCount = 2) val second = PageFormatInfo(pageHeight = HEIGHT_SMALL) val layout = DocumentLayoutInfo(pageFormats = listOf(first, second)) val result = layout.getPageFormatsWithDefault(null) assertEquals(1, result.size) // Second layer's height wins, first layer's column count is inherited. assertEquals(HEIGHT_SMALL, result.single().pageHeight) assertEquals(2, result.single().columnCount) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/FileResourceExporterNameSanitizationTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter import java.io.File import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [FileResourceExporter] sanitizing file names. */ class FileResourceExporterNameSanitizationTest { private val exporter = FileResourceExporter(location = File("."), write = false) private fun resource(name: String): OutputResource = TextOutputArtifact(name = name, content = "", type = ArtifactType.AUTO) private fun sanitize(name: String): String = resource(name).accept(exporter).name @Test fun `sane file name`() { assertEquals("hello", sanitize("hello")) } @Test fun `special characters`() { assertEquals("h-e-l-l-o", sanitize("h/e\\l:l*o")) } @Test fun `consecutivespecial characters`() { assertEquals("h-i", sanitize("h::::::i")) } @Test fun `accepted special characters`() { assertEquals("file_name-123@abc", sanitize("file_name-123@abc")) } @Test fun spaces() { assertEquals("file-name", sanitize("file name")) } @Test fun `leading dot`() { assertEquals("-hiddenfile", sanitize(".hiddenfile")) } @Test fun `trailing dot`() { assertEquals("file-", sanitize("file.")) } @Test fun `dot in the middle`() { assertEquals("file.name", sanitize("file.name")) } @Test fun `only special characters`() { assertEquals("-", sanitize("/\\:*?\"<>:|")) } @Test fun `multiple leading dots`() { assertEquals("-..file", sanitize("...file")) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/FontTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.core.misc.font.resolver.FontFamilyResolver import org.junit.Assume.assumeTrue import java.awt.GraphicsEnvironment import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs /** * Tests for font-related operations. */ class FontTest { @Test fun `system font`() { val name = "Impact" assumeTrue(name in GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames) assertIs( FontFamilyResolver.SYSTEM.resolve(name, workingDirectory = File(".")), ) } @Test fun `local media font`() { assertIs( FontFamilyResolver.SYSTEM.resolve("path/to/font.ttf", workingDirectory = File(".")), ) } @Test fun `remote media font`() { val url = "https://example.com/fonts/font.ttf" assertIs( FontFamilyResolver.SYSTEM.resolve(url, workingDirectory = null), ) } @Test fun `google font`() { val font = FontFamilyResolver.SYSTEM.resolve("GoogleFonts:Noto Sans", workingDirectory = null) assertIs(font) assertEquals("Noto Sans", font.name) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/FootnoteResolutionTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.getIndex import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.reference.FootnoteResolverHook import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull /** * Tests for resolving footnote references to their definitions. */ class FootnoteResolutionTest { private val context = MutableContext(QuarkdownFlavor) private fun def(label: String) = FootnoteDefinition( label = label, text = buildInline { text("This is a footnote definition for $label.") }, ) private fun ref(label: String) = ReferenceFootnote( label = label, fallback = { throw UnsupportedOperationException("No fallback for $label") }, ) private val definition1 = def("footnote1") private val reference1 = ref("footnote1") private val definition2 = def("footnote2") private val reference2 = ref("footnote2") private val invalidReference = ref("invalidFootnote") private fun traverse(root: Node) { ObservableAstIterator() .attach(FootnoteResolverHook(context)) .traverse(root as NestableNode) } @Test fun `reference after definition`() { val root = buildBlock { root { +definition1 +reference1 } } traverse(root) assertEquals( definition1, reference1.getDefinition(context), ) assertEquals(0, definition1.getIndex(context)) } @Test fun `reference before definition`() { val root = buildBlock { root { +reference1 +definition1 } } traverse(root) assertEquals( definition1, reference1.getDefinition(context), ) assertEquals(0, definition1.getIndex(context)) } @Test fun `invalid reference`() { val root = buildBlock { root { +invalidReference +definition1 } } traverse(root) assertNull(reference1.getDefinition(context)) assertNull(definition1.getIndex(context)) } @Test fun `definitions in different order`() { val root = buildBlock { root { +reference2 +reference1 +definition1 +definition2 } } traverse(root) assertEquals(0, definition2.getIndex(context)) assertEquals(1, definition1.getIndex(context)) } @Test fun `multiple references to the same definition`() { val root = buildBlock { root { +definition1 +reference1 +reference1 +definition2 +reference2 +reference2 +reference2 } } traverse(root) assertEquals( definition1, reference1.getDefinition(context), ) assertEquals(0, definition1.getIndex(context)) assertEquals(1, definition2.getIndex(context)) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/FunctionNodeExpansionTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentType import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.call.FunctionCallNodeExpander import com.quarkdown.core.function.library.LibraryRegistrant import com.quarkdown.core.function.library.loader.MultiFunctionLibraryLoader import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.core.util.node.toPlainText import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tests for functions called from a Quarkdown source. * For independent function call tests see [StandaloneFunctionTest]. */ class FunctionNodeExpansionTest { private lateinit var context: MutableContext private lateinit var expander: FunctionCallNodeExpander @Suppress("MemberVisibilityCanBePrivate") fun sum( a: Number, b: Number, ) = NumberValue(a.toFloat() + b.toFloat()) @Name("customfunction") @NotForDocumentType(DocumentType.SLIDES) fun myFunction(x: String) = StringValue(x) @Suppress("MemberVisibilityCanBePrivate") fun echoBoolean(value: Boolean) = BooleanValue(value) @Suppress("MemberVisibilityCanBePrivate") fun echoEnum(value: Container.Alignment) = StringValue(value.name) @Suppress("MemberVisibilityCanBePrivate") fun resourceContent(path: String) = StringValue(javaClass.getResourceAsStream("/function/$path")!!.reader().readText()) @Suppress("MemberVisibilityCanBePrivate") fun setAndEchoDocumentName( @Injected context: MutableContext, name: String, ): StringValue { context.documentInfo = context.documentInfo.copy(name = name) return StringValue(context.documentInfo.name!!) } @Suppress("MemberVisibilityCanBePrivate") fun makeQuote(body: MarkdownContent) = NodeValue( BlockQuote(children = body.children), ) @BeforeTest fun setup() { context = MutableContext(QuarkdownFlavor) // Initialization attaches the pipeline to the context. // This is used to parse Markdown content in arguments. context.attachMockPipeline() val library = MultiFunctionLibraryLoader("lib").load( moduleOf( ::sum, ::myFunction, ::echoBoolean, ::echoEnum, ::resourceContent, ::setAndEchoDocumentName, ::makeQuote, ), ) LibraryRegistrant(context).registerAll(listOf(library)) expander = FunctionCallNodeExpander(context, BasePipelineErrorHandler()) } @Test fun `sum expansion`() { val node = FunctionCallNode( context, "sum", listOf( FunctionCallArgument(DynamicValue("2")), FunctionCallArgument(DynamicValue("3")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) assertNodeEquals(Text("5"), node.children.first()) } @Test fun `sum expansion, failing`() { val node = FunctionCallNode( context, "sum", listOf( FunctionCallArgument(DynamicValue("2")), FunctionCallArgument(DynamicValue("a")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) with(node.children.first()) { assertIs(this) // Error box assertTrue("sum(" in (this.children.first() as TextNode).text.toPlainText()) // Error message } } @Test fun `custom function name`() { val node = FunctionCallNode( context, "customfunction", listOf( FunctionCallArgument(DynamicValue("abc")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) assertNodeEquals(Text("abc"), node.children.first()) } @Test fun `custom function name, failing`() { val node = FunctionCallNode( context, "myFunction", listOf( FunctionCallArgument(DynamicValue("abc")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) with(node.children.first()) { assertIs(this) // Error box assertTrue("reference" in (this.children.first() as TextNode).text.toPlainText()) // Unresolved reference error message } } @Test fun `resource content expansion, failing`() { val node = FunctionCallNode( context, "resourceContent", listOf( FunctionCallArgument(DynamicValue("non-existant-resource")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) with(node.children.first()) { assertIs(this) // Error box } } @Test fun `resource content expansion as inline`() { val node = FunctionCallNode( context, "resourceContent", listOf( FunctionCallArgument(DynamicValue("hello.txt")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) assertNodeEquals(Text("Hello Quarkdown!"), node.children.first()) } @Test fun `resource content expansion as block`() { val node = FunctionCallNode( context, "resourceContent", listOf( FunctionCallArgument(DynamicValue("hello.txt")), ), isBlock = true, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) // The function call is block, so the output is wrapped in a paragraph. assertNodeEquals(Paragraph(listOf(Text("Hello Quarkdown!"))), node.children.first()) } @Test fun `boolean expansion`() { val node = FunctionCallNode( context, "echoBoolean", listOf( FunctionCallArgument(DynamicValue("yes")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) assertNodeEquals(CheckBox(isChecked = true), node.children.first()) } @Test fun `enum lookup, failing`() { val node = FunctionCallNode( context, "echoEnum", listOf( FunctionCallArgument(DynamicValue("non-existant-value")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) with(node.children.first()) { assertIs(this) // Error box assertTrue("No such element" in (this.children.first() as TextNode).text.toPlainText()) // Error message } } @Test fun `context injection`() { val node = FunctionCallNode( context, "setAndEchoDocumentName", listOf( FunctionCallArgument(DynamicValue("New name")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) assertNull(context.documentInfo.name) expander.expandAll() assertEquals(1, node.children.size) assertEquals("New name", context.documentInfo.name) assertNodeEquals(Text("New name"), node.children.first()) } @Test fun `markdown argument`() { val node = FunctionCallNode( context, "makeQuote", listOf( FunctionCallArgument(DynamicValue("Hello **world**")), ), isBlock = false, ) context.register(node) assertTrue(node.children.isEmpty()) expander.expandAll() assertEquals(1, node.children.size) assertNodeEquals( BlockQuote( children = listOf( Paragraph( listOf(Text("Hello "), Strong(listOf(Text("world")))), ), ), ), node.children.first(), ) } @Test fun `invalid document type`() { context.documentInfo = DocumentInfo(type = DocumentType.SLIDES) val node = FunctionCallNode( context, "myFunction", listOf(FunctionCallArgument(DynamicValue("abc"))), isBlock = false, ) context.register(node) expander.expandAll() with(node.children.first()) { assertIs(this) assertEquals(Box.Type.ERROR, this.type) } } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/GraphTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.graph.DirectedGraph import com.quarkdown.core.graph.VisitableOnceGraph import com.quarkdown.core.graph.visitableOnce import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for the graph data structure. */ class GraphTest { private val graph = DirectedGraph() @Test fun empty() { assertTrue(graph.vertices.isEmpty()) } @Test fun addVertex() { val new = graph.addVertex(1) assertContains(new.vertices, 1) assertTrue(new.getNeighbors(1).none()) assertTrue(graph.vertices.isEmpty()) } @Test fun addEdge() { val new = graph .addVertex(1) .addVertex(2) .addEdge(1, 2) assertContains(new.vertices, 1) assertContains(new.vertices, 2) assertTrue(new.getNeighbors(1).contains(2)) assertTrue(new.getNeighbors(2).none()) } @Test fun getNeighbors() { val new = graph .addVertex(1) .addVertex(2) .addVertex(3) .addEdge(1, 2) .addEdge(2, 1) .addEdge(2, 3) assertEquals(setOf(2), new.getNeighbors(1).toSet()) assertEquals(setOf(1, 3), new.getNeighbors(2).toSet()) assertTrue(new.getNeighbors(3).none()) } @Test fun addVertexAndEdge() { val new = graph .addVertex(1) .addVertexAndEdge(vertex = 2, edgeFrom = 1, edgeTo = 2) assertContains(new.vertices, 1) assertContains(new.vertices, 2) assertTrue(new.getNeighbors(1).contains(2)) assertTrue(new.getNeighbors(2).none()) } @Test fun `visitable once`() { var visitableOnce = graph.visitableOnce .addVertex(1) .addVertex(2) .addVertex(3) .addEdge(1, 2) .addEdge(2, 1) .addEdge(2, 3) .addEdge(3, 1) val update = { updated: VisitableOnceGraph -> visitableOnce = updated } assertEquals(setOf(2), visitableOnce.visitNeighbors(1, update)) assertEquals(emptySet(), visitableOnce.visitNeighbors(1, update)) // 2 is already visited assertEquals(setOf(1), visitableOnce.visitNeighbors(3, update)) assertEquals(setOf(3), visitableOnce.visitNeighbors(2, update)) // 1 is already visited } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/InlineParserTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceDefinitionFootnote import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.size.cm import com.quarkdown.core.document.size.inch import com.quarkdown.core.document.size.mm import com.quarkdown.core.document.size.percent import com.quarkdown.core.document.size.px import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.misc.color.Color import com.quarkdown.core.util.node.toPlainText import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull /** * Parser tests for inline content. */ class InlineParserTest { /** * Tokenizes and parses a [source] code. * @param source source code * @param assertType if `true`, asserts each output node is of type [T] * @param flavor Markdown flavor to use * @param T type of the nodes to output * @return iterator of the parsed nodes */ private inline fun inlineIterator( source: CharSequence, assertType: Boolean = true, flavor: MarkdownFlavor = QuarkdownFlavor, ): Iterator { val lexer = flavor.lexerFactory.newInlineLexer(source) val parser = flavor.parserFactory.newParser(MutableContext(flavor)) return nodesIterator(lexer, parser, assertType) } @Test fun escape() { // EscapeToken is parsed into PlainText. val nodes = inlineIterator(readSource("/parsing/inline/escape.md")) assertEquals("#", nodes.next().text) assertEquals("!", nodes.next().text) assertEquals(".", nodes.next().text) assertEquals(",", nodes.next().text) assertEquals("[", nodes.next().text) assertEquals("]", nodes.next().text) } @Test fun entity() { val nodes = inlineIterator(readSource("/parsing/inline/entity.md")) // Decimal assertEquals(35.toChar().toString(), nodes.next().text) assertEquals(1234.toChar().toString(), nodes.next().text) assertEquals(992.toChar().toString(), nodes.next().text) assertEquals(65533.toChar().toString(), nodes.next().text) // Hexadecimal assertEquals(0x22.toChar().toString(), nodes.next().text) assertEquals(0xD06.toChar().toString(), nodes.next().text) assertEquals(0xCAB.toChar().toString(), nodes.next().text) // HTML assertEquals(" ", nodes.next().text) assertEquals("&", nodes.next().text) assertEquals("©", nodes.next().text) assertEquals("Æ", nodes.next().text) } @Test fun link() { val nodes = inlineIterator(readSource("/parsing/inline/link.md")) with(nodes.next()) { with(label.first()) { assertIs(this) assertEquals("foo", text) } assertEquals("https://google.com", url) assertNull(title) } repeat(2) { with(nodes.next()) { with(label.first()) { assertIs(this) assertEquals("foo", text) } assertEquals("https://google.com", url) assertEquals(title, "Title") } } // Autolink (first: diamond, second: URL) repeat(2) { with(nodes.next()) { assertEquals("https://google.com", url) with(label.first()) { assertIs(this) assertEquals(url, text) } assertNull(title) } } assertFalse(nodes.hasNext()) } @Test fun subdocumentLink() { val nodes = inlineIterator(readSource("/parsing/inline/subdocumentlink.md")) repeat(2) { with(nodes.next()) { assertEquals("path/to/file.qd", url) assertNull(anchor) } } with(nodes.next()) { assertEquals("path/to/file.qd", url) assertEquals("anchor", anchor) } with(nodes.next()) { assertEquals("path/to/file.qd", url) assertEquals("anchor#anchor", anchor) } with(nodes.next()) { assertEquals("path/to/file.md", url) assertNull(anchor) } assertFalse(nodes.hasNext()) } @Test fun referenceLink() { val nodes = inlineIterator(readSource("/parsing/inline/reflink.md")) with(nodes.next()) { with(label.first()) { assertIs(this) assertEquals("label", text) } assertNodeEquals(Text("ref"), referenceLabel.first()) } repeat(2) { with(nodes.next()) { with(label.first()) { assertIs(this) assertEquals("ref", text) } assertNodeEquals(Text("ref"), referenceLabel.first()) } } } @Test fun referenceFootnote() { val nodes = inlineIterator(readSource("/parsing/inline/reffootnote.md")) assertEquals("label", nodes.next().label) assertEquals("1", nodes.next().label) assertNodeEquals(Text("[^2]"), nodes.next().fallback()) } @Test fun `all-in-one reference footnote`() { val nodes = inlineIterator(readSource("/parsing/inline/reffootnote-all-in-one.md")) with(nodes.next()) { assertEquals("abc", label) assertEquals("this is a definition!", definition.toPlainText()) } with(nodes.next()) { assertEquals( label.length, UUID .randomUUID() .toString() .length, ) assertEquals("this is an anonymous definition!", definition.toPlainText()) } } @Test fun image() { val nodes = inlineIterator(readSource("/parsing/inline/image.md")) with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("foo", text) } assertEquals("/img", link.url) assertNull(link.title) assertNull(width) assertNull(height) assertNull(referenceId) } repeat(2) { with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("foo", text) } assertEquals("/img", link.url) assertEquals(link.title, "Title") assertNull(width) assertNull(height) assertNull(referenceId) } } with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("foo", text) } assertEquals("/img", link.url) assertEquals(link.title, "Title") assertEquals(150.px, width) assertEquals(100.px, height) } with(nodes.next()) { assertEquals(150.px, width) assertNull(height) } with(nodes.next()) { assertNull(width) assertEquals(100.px, height) } with(nodes.next()) { assertNull(width) assertNull(height) } with(nodes.next()) { assertEquals(140.px, width) assertNull(height) } with(nodes.next()) { assertEquals(2.0.cm, width) assertEquals(4.2.inch, height) } with(nodes.next()) { assertEquals(20.0.mm, width) assertEquals(3.0.cm, height) } with(nodes.next()) { assertEquals(2.px, width) assertEquals(3.px, height) } repeat(3) { with(nodes.next()) { assertEquals(50.percent, width) assertEquals(5.percent, height) } } with(nodes.next()) { assertEquals(70.percent, width) assertNull(height) } with(nodes.next()) { assertNull(width) assertNull(height) assertNull(link.title) assertEquals("custom-id", referenceId) } with(nodes.next()) { assertNotNull(width) assertNotNull(height) assertNotNull(link.title) assertEquals("custom-id", referenceId) } } @Test fun referenceImage() { val nodes = inlineIterator(readSource("/parsing/inline/refimage.md")) with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("label", text) } assertNodeEquals(Text("ref"), link.referenceLabel.first()) assertNull(width) assertNull(height) assertNull(referenceId) } repeat(2) { with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("ref", text) } assertNodeEquals(Text("ref"), link.referenceLabel.first()) assertNull(width) assertNull(height) } } with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("ref", text) } assertNodeEquals(Text("ref"), link.referenceLabel.first()) assertEquals(150.px, width) assertEquals(100.px, height) } with(nodes.next()) { with(link.label.first()) { assertIs(this) assertEquals("ref", text) } assertNodeEquals(Text("ref"), link.referenceLabel.first()) assertEquals(150.px, width) assertNull(height) } with(nodes.next()) { assertNull(width) assertNull(height) assertEquals("custom-id", referenceId) } with(nodes.next()) { assertNotNull(width) assertNotNull(height) assertEquals("custom-id", referenceId) } } @Test fun codeSpan() { val nodes = inlineIterator(readSource("/parsing/inline/codespan.md")) assertEquals("foo", nodes.next().text) assertEquals("foo ` bar", nodes.next().text) assertEquals("``", nodes.next().text) assertEquals(" `` ", nodes.next().text) assertEquals(" a", nodes.next().text) assertEquals("b", nodes.next().text) assertEquals("foo bar baz", nodes.next().text) // Color content. with(nodes.next()) { assertEquals("#FF00FF", text) assertEquals(CodeSpan.ColorContent(Color(255, 0, 255)), content) } } @Test fun strikethrough() { val nodes = inlineIterator(readSource("/parsing/inline/strikethrough.md"), assertType = false) assertEquals("foo", (nodes.next().children.first() as Text).text) assertEquals("Hi", (nodes.next().children.first() as Text).text) } @Test fun strong() { val nodes = inlineIterator(readSource("/parsing/inline/strong.md")) with(nodes.next()) { with(children.first()) { assertIs(this) assertEquals("foo", text) } } with(nodes.next()) { val content = children.iterator() with(content.next()) { assertIs(this) assertEquals("foo", text) } with(content.next()) { assertIs(this) assertIs(children.first()) assertEquals("bar", (children.first() as Text).text) } with(content.next()) { assertIs(this) assertEquals("baz", text) } } with(nodes.next()) { with(children.first()) { assertIs(this) assertEquals("foo_bar_baz", text) } } /* TODO fix for **foo*bar*** with(nodes.next()) { val content = children.iterator() with(content.next()) { assertIs(this) assertEquals("foo", text) } with(content.next()) { assertIs<Emphasis>(this) assertIs<PlainText>(children.first()) assertEquals("bar", (children.first() as PlainText).text) } } */ assertFalse(nodes.hasNext()) } @Test fun emphasis() { val nodes = inlineIterator<Emphasis>(readSource("/parsing/inline/emphasis.md")) repeat(2) { with(nodes.next()) { with(children.first()) { assertIs<Text>(this) assertEquals("foo", text) } } } with(nodes.next()) { val content = children.iterator() with(content.next()) { assertIs<Text>(this) assertEquals("foo", text) } with(content.next()) { assertIs<Strong>(this) assertIs<Text>(children.first()) assertEquals("bar", (children.first() as Text).text) } with(content.next()) { assertIs<Text>(this) assertEquals("baz", text) } } with(nodes.next()) { with(children.first()) { assertIs<Text>(this) assertEquals("foo_bar_baz", text) } } with(nodes.next()) { val content = children.iterator() with(content.next()) { assertIs<Text>(this) assertEquals("foo", text) } with(content.next()) { assertIs<Emphasis>(this) assertIs<Text>(children.first()) assertEquals("bar", (children.first() as Text).text) } with(content.next()) { assertIs<Text>(this) assertEquals("baz", text) } } with(nodes.next()) { with(children.first()) { assertIs<Text>(this) assertEquals("foo bar)", text) } } with(nodes.next()) { with(children.first()) { assertIs<Text>(this) assertEquals("(foo bar)", text) } } assertFalse(nodes.hasNext()) } @Test fun strongEmphasis() { val nodes = inlineIterator<StrongEmphasis>(readSource("/parsing/inline/strongemphasis.md")) with(nodes.next()) { with(children.first()) { assertIs<Text>(this) assertEquals("foo", text) } } with(nodes.next()) { val content = children.iterator() with(content.next()) { assertIs<Text>(this) assertEquals("foo", text) } with(content.next()) { assertIs<Emphasis>(this) assertIs<Text>(children.first()) assertEquals("bar", (children.first() as Text).text) } with(content.next()) { assertIs<Text>(this) assertEquals("baz", text) } } assertFalse(nodes.hasNext()) } @Test fun mathSpan() { val nodes = inlineIterator<MathSpan>(readSource("/parsing/inline/mathspan.md"), assertType = false) repeat(7) { assertEquals("Math expression", nodes.next().expression) } assertEquals($$"Math $expression", nodes.next().expression) assertEquals("Math", nodes.next().expression) assertEquals("expression", nodes.next().expression) assertFalse(nodes.hasNext()) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/LambdaTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.ScopeContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.data.LambdaParameter import com.quarkdown.core.function.value.wrappedAsValue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull /** * Tests for [Lambda] invocation. */ class LambdaTest { private val context = MutableContext(QuarkdownFlavor) @Test fun `no parameters, no return`() { val lambda = Lambda(context) { args, ctx -> assertNotEquals(context, ctx) assertIs<ScopeContext>(ctx) VoidValue } lambda.invoke<Unit, VoidValue>() } @Test fun `no parameters, with return`() { val lambda = Lambda(context) { args, _ -> StringValue("Hello") } assertEquals( "Hello", lambda.invoke<String, StringValue>().unwrappedValue, ) } @Test fun `one explicit parameter`() { val lambda = Lambda( context, listOf(LambdaParameter("myparam")), ) { args, ctx -> assertEquals(1, args.size) assertNotNull(ctx.getFunctionByName("myparam")) VoidValue } assertNull(context.getFunctionByName("myparam")) lambda.invoke<Unit, VoidValue>("Hello".wrappedAsValue()) } @Test fun `one implicit parameter`() { val lambda = Lambda(context) { args, ctx -> assertEquals(1, args.size) assertNotNull(ctx.getFunctionByName("1")) VoidValue } assertNull(context.getFunctionByName("1")) lambda.invoke<Unit, VoidValue>("Hello".wrappedAsValue()) } @Test fun `optional parameter`() { val lambda = Lambda( context, listOf(LambdaParameter("myparam", true)), ) { args, ctx -> StringValue((args.singleOrNull() as? StringValue)?.unwrappedValue ?: "none") } assertEquals( "Hello", lambda.invoke<String, StringValue>("Hello".wrappedAsValue()).unwrappedValue, ) assertEquals( "none", lambda.invoke<String, StringValue>().unwrappedValue, ) } @Test fun `two parameters, one optional`() { val lambda = Lambda( context, listOf( LambdaParameter("myparam1"), LambdaParameter("myparam2", true), ), ) { args, ctx -> StringValue( (0..1).joinToString { (args[it] as? StringValue)?.unwrappedValue ?: "none" }, ) } assertEquals( "Hello, world", lambda .invoke<String, StringValue>( "Hello".wrappedAsValue(), "world".wrappedAsValue(), ).unwrappedValue, ) assertEquals( "Hello, none", lambda.invoke<String, StringValue>("Hello".wrappedAsValue()).unwrappedValue, ) assertFails { lambda.invoke<String, StringValue>() } assertFails { lambda.invoke<String, StringValue>("Hello".wrappedAsValue(), "world".wrappedAsValue(), "extra".wrappedAsValue()) } } /** * @see com.quarkdown.core.function.value.AdaptableValue */ @Test fun `adapted result`() { val lambda = Lambda(context) { args, ctx -> NodeValue(Text("Hello")) } assertIs<Text>( lambda .invoke<MarkdownContent, MarkdownContentValue>() .unwrappedValue.children .single(), ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/LexerTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.flavor.MarkdownFlavor import com.quarkdown.core.flavor.base.BaseMarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.Token import com.quarkdown.core.lexer.TokenData import com.quarkdown.core.lexer.patterns.TextSymbolReplacement import com.quarkdown.core.lexer.regex.StandardRegexLexer import com.quarkdown.core.lexer.regex.pattern.TokenRegexPattern import com.quarkdown.core.lexer.tokens.BlockCodeToken import com.quarkdown.core.lexer.tokens.BlockQuoteToken import com.quarkdown.core.lexer.tokens.CodeSpanToken import com.quarkdown.core.lexer.tokens.CommentToken import com.quarkdown.core.lexer.tokens.DiamondAutolinkToken import com.quarkdown.core.lexer.tokens.EmphasisToken import com.quarkdown.core.lexer.tokens.EntityToken import com.quarkdown.core.lexer.tokens.EscapeToken import com.quarkdown.core.lexer.tokens.FencesCodeToken import com.quarkdown.core.lexer.tokens.FootnoteDefinitionToken import com.quarkdown.core.lexer.tokens.FunctionCallToken import com.quarkdown.core.lexer.tokens.HeadingToken import com.quarkdown.core.lexer.tokens.HorizontalRuleToken import com.quarkdown.core.lexer.tokens.ImageToken import com.quarkdown.core.lexer.tokens.InlineMathToken import com.quarkdown.core.lexer.tokens.LineBreakToken import com.quarkdown.core.lexer.tokens.LinkDefinitionToken import com.quarkdown.core.lexer.tokens.LinkToken import com.quarkdown.core.lexer.tokens.MultilineMathToken import com.quarkdown.core.lexer.tokens.NewlineToken import com.quarkdown.core.lexer.tokens.OnelineMathToken import com.quarkdown.core.lexer.tokens.OrderedListToken import com.quarkdown.core.lexer.tokens.PageBreakToken import com.quarkdown.core.lexer.tokens.ParagraphToken import com.quarkdown.core.lexer.tokens.PlainTextToken import com.quarkdown.core.lexer.tokens.ReferenceImageToken import com.quarkdown.core.lexer.tokens.ReferenceLinkToken import com.quarkdown.core.lexer.tokens.SetextHeadingToken import com.quarkdown.core.lexer.tokens.StrongEmphasisToken import com.quarkdown.core.lexer.tokens.StrongToken import com.quarkdown.core.lexer.tokens.TableToken import com.quarkdown.core.lexer.tokens.TextSymbolToken import com.quarkdown.core.lexer.tokens.UnorderedListToken import com.quarkdown.core.lexer.tokens.UrlAutolinkToken import com.quarkdown.core.parser.walker.funcall.FunctionCallWalkerParser import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertIsNot import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tokenization tests. * @see Lexer */ class LexerTest { private fun blockLexer( source: CharSequence, flavor: MarkdownFlavor = QuarkdownFlavor, ) = flavor.lexerFactory.newBlockLexer(source) private fun inlineLex(source: CharSequence) = QuarkdownFlavor.lexerFactory .newInlineLexer(source.trim()) .tokenize() .filter { it !is NewlineToken } .iterator() @Test fun regex() { val wrap: (TokenData) -> Token = { ParagraphToken(it) } val lexer = StandardRegexLexer( "ABC\nABB\nDEF\nGHI\nDE", listOf( TokenRegexPattern( name = "FIRST", wrap = wrap, regex = "AB.", ), TokenRegexPattern( name = "SECOND", wrap = wrap, regex = "DE.?", ), TokenRegexPattern( name = "NEWLINE", wrap = wrap, regex = "\\R", ), ), fillTokenType = wrap, ) val tokens = lexer.tokenize().iterator() fun nextText() = tokens.next().data.text assertEquals("ABC", nextText()) assertEquals("\n", nextText()) assertEquals("ABB", nextText()) assertEquals("\n", nextText()) assertEquals("DEF", nextText()) assertEquals("\n", nextText()) assertEquals("GHI", nextText()) assertEquals("\n", nextText()) assertEquals("DE", nextText()) } @Test fun blocks() { val tokens = blockLexer(readSource("/lexing/blocks.md")) .tokenize() .filter { it !is NewlineToken } .iterator() assertIs<HeadingToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<HeadingToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<SetextHeadingToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<UnorderedListToken>(tokens.next()) assertIs<UnorderedListToken>(tokens.next()) assertIs<OrderedListToken>(tokens.next()) assertIs<BlockQuoteToken>(tokens.next()) assertIs<BlockQuoteToken>(tokens.next()) assertIs<BlockQuoteToken>(tokens.next()) assertIs<BlockQuoteToken>(tokens.next()) assertIs<BlockCodeToken>(tokens.next()) assertIs<FencesCodeToken>(tokens.next()) assertIs<MultilineMathToken>(tokens.next()) assertIs<OnelineMathToken>(tokens.next()) assertIs<PageBreakToken>(tokens.next()) assertIs<HorizontalRuleToken>(tokens.next()) assertIs<LinkDefinitionToken>(tokens.next()) assertIs<HorizontalRuleToken>(tokens.next()) assertIs<FootnoteDefinitionToken>(tokens.next()) assertIs<FootnoteDefinitionToken>(tokens.next()) assertIs<TableToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<UnorderedListToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<ParagraphToken>(tokens.next()) assertFalse(tokens.hasNext()) } @Test fun emphasis() { val sources = readSource("/lexing/emphasis.md").split("${System.lineSeparator()}---${System.lineSeparator()}").iterator() repeat(2) { with(inlineLex(sources.next())) { assertIs<StrongToken>(next()) assertFalse(hasNext()) } } repeat(2) { with(inlineLex(sources.next())) { assertIs<EmphasisToken>(next()) assertFalse(hasNext()) } } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertFalse(hasNext()) } repeat(2) { with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertIs<EmphasisToken>(next()) assertFalse(hasNext()) } } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<StrongToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<StrongEmphasisToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongEmphasisToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<EmphasisToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex(sources.next())) { assertIs<PlainTextToken>(next()) assertIs<StrongToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } } @Test fun comments() { val tokens = inlineLex(readSource("/lexing/comment.md")) assertIsNot<CommentToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIsNot<CommentToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIsNot<CommentToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIsNot<CommentToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIsNot<CommentToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIsNot<CommentToken>(tokens.next()) } @Test fun escape() { val tokens = inlineLex(readSource("/lexing/escape.md")) assertIsNot<EscapeToken>(tokens.next()) // 'Text ' assertIs<EscapeToken>(tokens.next()) // \# assertIsNot<EscapeToken>(tokens.next()) // ' text \m ' assertIs<EscapeToken>(tokens.next()) // \! assertIsNot<EscapeToken>(tokens.next()) // ' ' assertIs<EscapeToken>(tokens.next()) // \. assertIs<EscapeToken>(tokens.next()) // \, assertIsNot<EscapeToken>(tokens.next()) // ' text' } @Test fun entity() { val tokens = inlineLex(readSource("/lexing/entity.md")) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<LineBreakToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EntityToken>(tokens.next()) } @Test fun inlineFunction() { val tokens = inlineLex(readSource("/lexing/inlinefunction.md")) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<StrongToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertFalse(tokens.hasNext()) } @Test fun inline() { val tokens = inlineLex(readSource("/lexing/inline.md")) assertIs<PlainTextToken>(tokens.next()) assertIs<EscapeToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<CodeSpanToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<CodeSpanToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<LinkToken>(tokens.next()) assertIs<LineBreakToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<StrongToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<EmphasisToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<StrongEmphasisToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<DiamondAutolinkToken>(tokens.next()) assertIs<UrlAutolinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<LinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<LinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ReferenceLinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ReferenceLinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ReferenceLinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ImageToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ReferenceImageToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<CommentToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<StrongToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<ReferenceLinkToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<InlineMathToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertFalse(tokens.hasNext()) } @Test fun textReplacement() { val tokens = inlineLex(readSource("/lexing/textreplacement.md")) fun assertSymbolEquals(symbol: TextSymbolReplacement) = with(tokens.next()) { assertIs<TextSymbolToken>(this) assertEquals(symbol, this.symbol) } assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.ELLIPSIS) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.COPYRIGHT) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.EM_DASH) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.EM_DASH) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.DOUBLE_RIGHT_ARROW) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.NOT_EQUAL) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.SINGLE_RIGHT_ARROW) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.LESS_EQUAL) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.GREATER_EQUAL) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.SINGLE_LEFT_ARROW) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.EN_DASH) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.ELLIPSIS) assertIs<PlainTextToken>(tokens.next()) // Soft line break assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_LEFT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_LEFT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_APOSTROPHE) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_LEFT_QUOTATION_MARK) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_QUOTATION_MARK) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TRADEMARK) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_LEFT_QUOTATION_MARK) assertIs<PlainTextToken>(tokens.next()) assertSymbolEquals(TextSymbolReplacement.TYPOGRAPHIC_RIGHT_QUOTATION_MARK) assertIs<PlainTextToken>(tokens.next()) } @Test fun flavors() { // Quarkdown features are not detected when using BaseMarkdownFlavor val tokens = blockLexer(readSource("/lexing/blocks.md"), flavor = BaseMarkdownFlavor).tokenize() assertTrue(tokens.none { it is MultilineMathToken }) assertTrue(tokens.none { it is OnelineMathToken }) assertTrue(tokens.any { it is BlockQuoteToken }) } @Test fun functionCall() { fun walk(source: CharSequence) = FunctionCallWalkerParser(source, allowsBody = true).parse() with(walk(".function")) { assertEquals("function", value.name) assertEquals(".function".length, endIndex) } with(walk(".function something")) { assertEquals("function", value.name) assertEquals(".function".length, endIndex) } with(walk(".function {x}")) { assertEquals("function", value.name) assertEquals(".function {x}".length, endIndex) with(value.arguments.single()) { assertEquals("x", value) assertNull(name) } } with(walk(".function {x} {y}")) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) assertEquals("y", value.arguments[1].value) } with(walk(".function {x {a} b} {y {hello {world}}} {}")) { assertEquals("function", value.name) assertEquals("x {a} b", value.arguments[0].value) assertEquals("y {hello {world}}", value.arguments[1].value) assertEquals("", value.arguments[2].value) } with(walk(".function firstname:{y} lastname:{z}")) { assertEquals("function", value.name) with(value.arguments[0]) { assertEquals("firstname", name) assertEquals("y", value) } with(value.arguments[1]) { assertEquals("lastname", name) assertEquals("z", value) } } with(walk(".function {x} firstname:{y} lastname:{z}")) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) with(value.arguments[1]) { assertEquals("firstname", name) assertEquals("y", value) } with(value.arguments[2]) { assertEquals("lastname", name) assertEquals("z", value) } } with( walk( """ .function { x } name:{ y } """.trimIndent(), ), ) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) with(value.arguments[1]) { assertEquals("name", name) assertEquals("y", value) } } with( walk( """ .function {x} {y} Body """.trimIndent(), ), ) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) assertEquals("y", value.arguments[1].value) assertEquals("Body", value.bodyArgument?.value) } with( walk( """ .function {x} {y} Body body body body body body """.trimIndent(), ), ) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) assertEquals("y", value.arguments[1].value) assertEquals("Body body\nbody body\nbody\n body", value.bodyArgument?.value) } with( walk( """ .function {x} {y} Body body body body body """.trimIndent(), ), ) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) assertEquals("y", value.arguments[1].value) assertEquals("Body body\nbody\n\nbody\n body", value.bodyArgument?.value) } with( walk( """ .foreach {1..3} Hi .sum {.1} {2} hello """.trimIndent(), ), ) { assertEquals("foreach", value.name) assertEquals("1..3", value.arguments[0].value) assertEquals("Hi .sum {.1} {2} hello", value.bodyArgument?.value) } with(walk(".function\n\n\nx")) { assertEquals("function", value.name) assertEquals(0, value.arguments.size) } with(walk(".function\n\n p\nx")) { assertEquals("function", value.name) assertEquals(0, value.arguments.size) assertEquals("p", value.bodyArgument?.value?.trim()) } with(walk(".function\n\n \np\nx")) { assertEquals("function", value.name) assertEquals(0, value.arguments.size) assertNull(value.bodyArgument) } with(walk(".function\n\nfunction")) { assertEquals("function", value.name) assertEquals(0, value.arguments.size) assertNull(value.bodyArgument) } with(walk(".function\n\nfunction {arg1} {arg2}")) { assertEquals("function", value.name) assertEquals(0, value.arguments.size) assertNull(value.bodyArgument) } with(walk(".foo::bar")) { assertEquals("foo", value.name) assertEquals(0, value.arguments.size) assertEquals("bar", value.next!!.name) assertEquals(0, value.next!!.arguments.size) } with(walk(".foo {a} {b}::bar {c}")) { assertEquals("foo", value.name) assertEquals(2, value.arguments.size) assertEquals("bar", value.next!!.name) assertEquals(1, value.next!!.arguments.size) } with(walk(".foo {a} {b}::bar {c}::baz {d}")) { assertEquals("foo", value.name) assertEquals(2, value.arguments.size) assertEquals("bar", value.next!!.name) assertEquals(1, value.next!!.arguments.size) assertEquals("baz", value.next!!.next!!.name) assertEquals( 1, value.next!! .next!! .arguments.size, ) } // Wrapped function calls. with(walk("{.function {x}}")) { assertEquals("function", value.name) assertEquals("{.function {x}}".length, endIndex) with(value.arguments.single()) { assertEquals("x", value) assertNull(name) } } with(walk("{.function {x} {y}}")) { assertEquals("function", value.name) assertEquals("{.function {x} {y}}".length, endIndex) assertEquals("x", value.arguments[0].value) assertEquals("y", value.arguments[1].value) } with(walk("{.function {x} name:{y}}")) { assertEquals("function", value.name) assertEquals("x", value.arguments[0].value) with(value.arguments[1]) { assertEquals("name", name) assertEquals("y", value) } } with(walk("{.foo {a}::bar {b}}")) { assertEquals("foo", value.name) assertEquals(1, value.arguments.size) assertEquals("bar", value.next!!.name) assertEquals(1, value.next!!.arguments.size) } } /** * Verifies that escaped function calls are not tokenized as [FunctionCallToken]s. */ @Test fun escapedFunctionCall() { val tokens = inlineLex("\\.func {x}") assertIs<EscapeToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertFalse(tokens.hasNext()) } /** * Verifies that adjacent inline function calls produce separate [FunctionCallToken]s. */ @Test fun adjacentInlineFunctionCalls() { val tokens = inlineLex(".a {x} .b {y}") assertIs<FunctionCallToken>(tokens.next()) assertIs<PlainTextToken>(tokens.next()) assertIs<FunctionCallToken>(tokens.next()) assertFalse(tokens.hasNext()) } /** * Verifies that a function call at the end of source is tokenized correctly. */ @Test fun functionCallAtEndOfSource() { val tokens = inlineLex(".func {x}") assertIs<FunctionCallToken>(tokens.next()) assertFalse(tokens.hasNext()) } /** * Verifies that deeply nested braces within function call arguments are handled correctly. */ @Test fun deeplyNestedBraces() { fun walk(source: CharSequence) = FunctionCallWalkerParser(source, allowsBody = true).parse() with(walk(".func {a {b {c {d}}}}")) { assertEquals("func", value.name) assertEquals("a {b {c {d}}}", value.arguments.single().value) } } /** * Verifies that wrapped function calls (e.g. `{.func {x}}`) are tokenized correctly. */ @Test fun wrappedInlineFunctionCall() { // Standalone wrapped call. with(inlineLex("{.func {x}}")) { assertIs<FunctionCallToken>(next()) assertFalse(hasNext()) } // Wrapped call between text (loose). with(inlineLex("hello {.func {x}} world")) { assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } // Wrapped call between text (tight). with(inlineLex("hello{.func {x}}world")) { assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } // Multiple wrapped calls. with(inlineLex("{.a {x}} {.b {y}}")) { assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertFalse(hasNext()) } // Wrapped call with multiple arguments. with(inlineLex("{.func {x} {y}}")) { val token = next() assertIs<FunctionCallToken>(token) assertEquals("func", token.walkerResult.value.name) assertEquals(2, token.walkerResult.value.arguments.size) assertFalse(hasNext()) } // Escaped wrapping brace: not treated as a wrapped call. with(inlineLex("\\{.func {x}}")) { assertIs<EscapeToken>(next()) assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } // Incomplete wrapped call: the opening brace is plain text, // and the function call falls back to the standard (non-wrapped) match. with(inlineLex("{.func {x}")) { assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertFalse(hasNext()) } } /** * Verifies that a function call immediately after punctuation is recognized. */ @Test fun functionCallAfterPunctuation() { with(inlineLex("(.func {x})")) { assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } with(inlineLex("/.func {x}/")) { assertIs<PlainTextToken>(next()) assertIs<FunctionCallToken>(next()) assertIs<PlainTextToken>(next()) assertFalse(hasNext()) } } /** * Verifies that the walker result on [FunctionCallToken] is correctly typed as [WalkedFunctionCall][com.quarkdown.core.parser.walker.funcall.WalkedFunctionCall]. */ @Test fun typedWalkerResult() { val tokens = QuarkdownFlavor.lexerFactory .newInlineLexer(".func {x}") .tokenize() .filterIsInstance<FunctionCallToken>() .toList() assertEquals(1, tokens.size) with(tokens.single()) { assertEquals("func", walkerResult.value.name) assertEquals( "x", walkerResult.value.arguments .single() .value, ) } } /** * Verifies that multiple consecutive block function calls are all tokenized correctly, * validating the iterative regex loop handles walker-driven position advances. */ @Test fun multipleBlockFunctionCalls() { val source = """ .first {a} .second {b} .third {c} """.trimIndent() val tokens = blockLexer(source) .tokenize() .filterIsInstance<FunctionCallToken>() .toList() assertEquals(3, tokens.size) assertEquals("first", tokens[0].walkerResult.value.name) assertEquals("second", tokens[1].walkerResult.value.name) assertEquals("third", tokens[2].walkerResult.value.name) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/LocaleTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.localization.jvm.JVMLocaleLoader import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tests for locale retrievals. * @see LocalizationTest */ class LocaleTest { private val retriever = JVMLocaleLoader @Test fun `default retriever`() { assertEquals(retriever, LocaleLoader.SYSTEM) } @Test fun english() { with(retriever.fromTag("en")) { assertNotNull(this) assertEquals(this, retriever.fromName("English")) assertEquals(this, retriever.find("English")) assertEquals(this, retriever.find("eNgLiSh")) assertEquals("en", code) assertEquals("en", tag) assertEquals("en", shortTag) assertEquals("English", displayName) assertEquals("English", localizedName) assertNull(countryCode) assertNull(localizedCountryName) } } @Test fun italian() { with(retriever.find("it")) { assertNotNull(this) assertEquals(this, retriever.fromName("Italian")) assertEquals(this, retriever.find("Italian")) assertEquals(this, retriever.find("iTaLiAn")) assertEquals("it", code) assertEquals("it", tag) assertEquals("it", shortTag) assertEquals("Italian", displayName) assertEquals("italiano", localizedName) assertNull(countryCode) assertNull(localizedCountryName) } } @Test fun `english-us`() { with(retriever.find("en-US")) { assertNotNull(this) assertEquals(this, retriever.find("English (United States)")) assertEquals(this, retriever.find("En-us")) assertEquals("en", code) assertEquals("en-US", tag) assertEquals("en", shortTag) assertEquals("English (United States)", displayName) assertEquals("English (United States)", localizedName) assertEquals("US", countryCode) assertEquals("United States", localizedCountryName) } } @Test fun `french-canada`() { with(retriever.find("fr-CA")) { assertNotNull(this) assertEquals(this, retriever.find("French (Canada)")) assertEquals("fr", code) assertEquals("fr-CA", tag) assertEquals("fr", shortTag) assertEquals("French (Canada)", displayName) assertEquals("français (Canada)", localizedName) assertEquals("CA", countryCode) assertEquals("Canada", localizedCountryName) } } @Test fun invalid() { assertNull(retriever.fromTag("nonexistent")) assertNull(retriever.fromName("nonexistent")) assertNull(retriever.find("nonexistent")) } @Test fun `all locales are loaded`() { assertTrue(retriever.all.iterator().hasNext()) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/LocalizationTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.localization.localizeOrDefault import com.quarkdown.core.context.localization.localizeOrNull import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.localization.LocaleNotSetException import com.quarkdown.core.localization.LocalizationKeyNotFoundException import com.quarkdown.core.localization.LocalizationLocaleNotFoundException import com.quarkdown.core.localization.LocalizationTable import com.quarkdown.core.localization.LocalizationTableNotFoundException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull /** * Tests for localization. * @see LocaleTest */ class LocalizationTest { private val loader = LocaleLoader.SYSTEM private fun context(locale: Locale?): Context { val table: LocalizationTable = mapOf( loader.fromName("English")!! to mapOf( "morning" to "Good morning", "evening" to "Good evening", ), loader.fromName("Italian")!! to mapOf( "morning" to "Buongiorno", "evening" to "Buonasera", ), ) return MutableContext(QuarkdownFlavor).apply { documentInfo = DocumentInfo(locale = locale) localizationTables["mytable"] = table } } @Test fun `locale not set`() { val context = context(null) assertFailsWith<LocaleNotSetException> { context.localize("mytable", "morning") } } @Test fun `english localization`() { val context = context(loader.fromName("English")!!) assertEquals("Good morning", context.localize("mytable", "morning")) assertEquals("Good evening", context.localize("mytable", "evening")) } @Test fun `italian localization`() { val context = context(loader.fromName("Italian")!!) assertEquals("Buongiorno", context.localize("mytable", "morning")) assertEquals("Buonasera", context.localize("mytable", "evening")) } @Test fun `invalid key`() { val context = context(loader.fromName("English")!!) assertFailsWith<LocalizationKeyNotFoundException> { context.localize("mytable", "afternoon") } assertFailsWith<LocalizationTableNotFoundException> { context.localize("sometable", "morning") } } @Test fun `invalid locale`() { val context = context(loader.fromName("French")!!) assertFailsWith<LocalizationLocaleNotFoundException> { context.localize("mytable", "morning") } } @Test fun `invalid locale with null`() { val context = context(loader.fromName("French")!!) assertNull(context.localizeOrNull("mytable", "morning")) } @Test fun `invalid locale with default`() { val context = context(loader.fromName("French")!!) assertEquals("Good morning", context.localizeOrDefault("mytable", "morning")) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/MediaTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.media.StoredMediaProperty import com.quarkdown.core.ast.media.getStoredMedia import com.quarkdown.core.media.LocalMedia import com.quarkdown.core.media.Media import com.quarkdown.core.media.MediaVisitor import com.quarkdown.core.media.RemoteMedia import com.quarkdown.core.media.ResolvableMedia import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.media.storage.MutableMediaStorage import com.quarkdown.core.media.storage.StoredMedia import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue private const val WORKING_DIR_PATH = "src/test/resources" private const val LOCAL_DIR = MEDIA_SUBDIRECTORY_NAME private const val LOCAL_ICON = "$LOCAL_DIR/icon.png" private const val LOCAL_BANNER = "$LOCAL_DIR/banner.png" private const val REMOTE_IMAGE = "https://example.com/image.jpg" private const val REMOTE_LOGO = "https://iamgio.eu/quarkdown/img/logo-light.svg" private const val REMOTE_LOGO_OUT_NAME = "https-iamgio.eu-quarkdown-img-logo-light.svg" private const val INVALID_PATH = "nonexistent" private const val OUT_DIR = MEDIA_SUBDIRECTORY_NAME private const val OUT_PATH_1 = "$OUT_DIR/path1/logo.png" private const val OUT_PATH_2 = "$OUT_DIR/path2/logo.png" /** * Test for the media storage and media resolution. */ class MediaTest { private val workingDir = File(WORKING_DIR_PATH) private lateinit var attributes: MutableAstAttributes @BeforeTest fun setUp() { attributes = MutableAstAttributes() } private fun createLocalOnlyStorage(): MutableMediaStorage = MutableMediaStorage( ReadOnlyMediaStorageOptions( enableLocalMediaStorage = true, enableRemoteMediaStorage = false, ), ) private fun createLocalAndRemoteStorage(): MutableMediaStorage = MutableMediaStorage( ReadOnlyMediaStorageOptions( enableLocalMediaStorage = true, enableRemoteMediaStorage = true, ), ) private val selfVisitor = object : MediaVisitor<Media> { override fun visit(media: LocalMedia) = media override fun visit(media: RemoteMedia) = media } @Test fun `resolve valid local media`() { val media = ResolvableMedia(File(WORKING_DIR_PATH, LOCAL_ICON).path).accept(selfVisitor) assertIs<LocalMedia>(media) } @Test fun `resolve valid remote media`() { val media = ResolvableMedia(REMOTE_IMAGE).accept(selfVisitor) assertIs<RemoteMedia>(media) } @Test fun `throw on invalid path`() { assertFails { ResolvableMedia(INVALID_PATH).accept(selfVisitor) } } @Test fun `throw on directory path`() { assertFails { ResolvableMedia(WORKING_DIR_PATH).accept(selfVisitor) } } @Test fun `register and resolve local media`() { val storage = createLocalOnlyStorage() storage.register(LOCAL_ICON, workingDirectory = workingDir) val stored = storage.resolve(LOCAL_ICON) assertEquals(1, storage.all.size) assertTrue(stored!!.name.startsWith("icon@")) assertTrue(stored.name.endsWith(".png")) } @Test fun `resolve multiple local media entries`() { val storage = createLocalOnlyStorage() storage.register(LOCAL_ICON, workingDirectory = workingDir) storage.register(LOCAL_BANNER, workingDirectory = workingDir) assertEquals(2, storage.all.size) val resolved = storage.resolve(LOCAL_BANNER) assertTrue(resolved!!.name.startsWith("banner@")) assertTrue(resolved.name.endsWith(".png")) } @Test fun `unresolved remote media in local-only storage`() { val storage = createLocalOnlyStorage() storage.register(REMOTE_LOGO, workingDirectory = null) assertEquals(0, storage.resolve(REMOTE_LOGO)?.let { 1 } ?: 0) } @Test fun `register and resolve both local and remote media`() { val storage = createLocalAndRemoteStorage() storage.register(LOCAL_ICON, workingDir) storage.register(LOCAL_BANNER, workingDir) storage.register(REMOTE_LOGO, null) assertEquals(3, storage.all.size) assertEquals(REMOTE_LOGO_OUT_NAME, storage.resolve(REMOTE_LOGO)?.name) } @Test fun `media with same filename but different paths do not collide`() { val storage = createLocalAndRemoteStorage() storage.register(OUT_PATH_1, workingDir) storage.register(OUT_PATH_2, workingDir) val name1 = storage.resolve(OUT_PATH_1)!!.name val name2 = storage.resolve(OUT_PATH_2)!!.name assertNotEquals(name1, name2) } private fun Node.attach(media: StoredMedia?) { if (media == null) return attributes.of(this) += StoredMediaProperty(media) } private fun remoteImage(media: StoredMedia?) = Image( Link( label = listOf(), url = REMOTE_LOGO, title = null, ).apply { attach(media) }, width = null, height = null, ) private fun localImage(media: StoredMedia?) = Image( Link( label = listOf(), url = LOCAL_ICON, title = null, ).apply { attach(media) }, width = null, height = null, ) @Test fun `remote media path retrieval`() { val storage = createLocalAndRemoteStorage() val media = storage.register(REMOTE_LOGO, workingDirectory = null)!! val image = remoteImage(media) assertEquals("$OUT_DIR/$REMOTE_LOGO_OUT_NAME", image.link.getStoredMedia(attributes)?.path) } @Test fun `local media path retrieval`() { val storage = createLocalAndRemoteStorage() val media = storage.register(LOCAL_ICON, workingDirectory = workingDir)!! val image = localImage(media) image.link.getStoredMedia(attributes)?.path?.let { assertTrue(it.startsWith("$OUT_DIR/icon@")) assertTrue(it.endsWith(".png")) } } @Test fun `denied remote media path retrieval`() { val storage = createLocalOnlyStorage() val media = storage.register(REMOTE_LOGO, workingDirectory = null) val image = remoteImage(media) assertNull(media) assertNull(image.link.getStoredMedia(attributes)) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/MiscTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.BinaryOutputArtifact import com.quarkdown.core.pipeline.output.LazyOutputArtifact import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tests for miscellaneous classes. */ class MiscTest { @Test fun `resource export`() { val dir = Files.createTempDirectory("quarkdown-resource-test") val exporter = FileResourceExporter(dir.toFile()) with("Hello, world!".repeat(1000)) { assertEquals( this, TextOutputArtifact("Artifact 1", this, ArtifactType.HTML) .accept(exporter) .also { assertEquals("Artifact-1.html", it.name) } .readText(), ) assertContentEquals( this.toByteArray(), BinaryOutputArtifact("a/rt*fact::2", this.toByteArray().toList(), ArtifactType.JAVASCRIPT) .accept(exporter) .also { assertEquals("a-rt-fact-2.js", it.name) } .readBytes(), ) } with("Quarkdown".repeat(1000)) { LazyOutputArtifact("artifact3", { this.toByteArray().toList() }, ArtifactType.CSS) .accept(exporter) .also { assertEquals("artifact3.css", it.name) } .let { file -> assertEquals(this, file.readText()) assertContentEquals(this.toByteArray(), file.readBytes()) } } LazyOutputArtifact .internal( resource = "/media/icon.png", name = "artif@ct 4.png", type = ArtifactType.AUTO, referenceClass = this::class, ).run { assertContentEquals( this::class.java.getResourceAsStream("/media/icon.png")!!.readBytes(), this .accept(exporter) .also { assertEquals("artif@ct-4.png", it.name) } .readBytes(), ) } LazyOutputArtifact .internalOrNull( resource = "nonexisting.png", name = "artifact.png", type = ArtifactType.AUTO, referenceClass = this::class, ).let { assertNull(it) } val group = OutputResourceGroup( "Group 1", setOf( TextOutputArtifact("Artifact 5", "Hello, world!", ArtifactType.HTML), BinaryOutputArtifact("arti-fact6", "Quarkdown".toByteArray().toList(), ArtifactType.JAVASCRIPT), LazyOutputArtifact("artifact7", { "Quarkdown".toByteArray().toList() }, ArtifactType.CSS), OutputResourceGroup( "Group 2", setOf( TextOutputArtifact("Artifact 8", "Hello, world!", ArtifactType.HTML), BinaryOutputArtifact("art*fact/9", "Quarkdown".toByteArray().toList(), ArtifactType.JAVASCRIPT), ), ), LazyOutputArtifact.internal( referenceClass = this::class, resource = "/media/banner.png", name = "artif@ct 10.png", type = ArtifactType.AUTO, ), BinaryOutputArtifact( "artifact11", "Hello world".repeat(100).toByteArray().toList(), ArtifactType.JAVASCRIPT, ), ), ) val groupFile = group.accept(exporter) assertTrue(groupFile.isDirectory) val files = groupFile.listFiles()!! assertEquals(6, files.size) assertEquals(1, files.count { it.extension == "html" }) assertEquals(2, files.count { it.extension == "js" }) assertEquals(1, files.count { it.extension == "css" }) assertEquals(1, files.count { it.extension == "png" }) val subGroup = files.single { it.isDirectory } subGroup.listFiles()!!.let { subFiles -> assertEquals(2, subFiles.size) assertEquals(1, subFiles.count { it.extension == "html" }) assertEquals(1, subFiles.count { it.extension == "js" }) } } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/NumberingFormatTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.attributes.location.SectionLocation import com.quarkdown.core.document.numbering.AlphaNumberingSymbol import com.quarkdown.core.document.numbering.DecimalNumberingSymbol import com.quarkdown.core.document.numbering.NumberingFixedSymbol import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.util.StringCase import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs /** * Tests for [NumberingFormat]. */ class NumberingFormatTest { private fun NumberingFormat.format( vararg levels: Int, allowMismatchingLength: Boolean = true, ) = format(SectionLocation(levels.toList()), allowMismatchingLength) @Test fun `decimal numbering symbol mapping`() { assertEquals("3", DecimalNumberingSymbol.map(3)) } @Test fun `alpha numbering symbol lower case mapping`() { assertEquals("b", AlphaNumberingSymbol(StringCase.Lower).map(2)) } @Test fun `alpha numbering symbol upper case mapping`() { assertEquals("C", AlphaNumberingSymbol(StringCase.Upper).map(3)) } @Test fun `numbering format parsing from string`() { val format = NumberingFormat.fromString("1.1.a-A") with(format.symbols.iterator()) { assertIs<DecimalNumberingSymbol>(next()) assertEquals('.', (next() as NumberingFixedSymbol).value) assertIs<DecimalNumberingSymbol>(next()) assertEquals('.', (next() as NumberingFixedSymbol).value) next().let { assertIs<AlphaNumberingSymbol>(it) assertEquals(StringCase.Lower, it.case) } assertEquals('-', (next() as NumberingFixedSymbol).value) next().let { assertIs<AlphaNumberingSymbol>(it) assertEquals(StringCase.Upper, it.case) } } } @Test fun `basic numbering formatting`() { val format = NumberingFormat.fromString("1.1.a-A") assertEquals("1.1.a-A", format.format(1, 1, 1, 1)) assertEquals("2.2.b-B", format.format(2, 2, 2, 2)) assertEquals("2.1.c-A", format.format(2, 1, 3, 1)) } @Test fun `advanced numbering formatting`() { val format = NumberingFormat.fromString("1.1.a-A") assertEquals("3.2.d-P", format.format(3, 2, 4, 16)) assertEquals("12.20.e-A", format.format(12, 20, 5, 1)) assertEquals("0.0.0-0", format.format(0, 0, 0, 0)) } @Test fun `partial level formatting`() { val format = NumberingFormat.fromString("1.1.a-A") assertEquals("2.1.b", format.format(2, 1, 2)) assertEquals("1", format.format(1)) } @Test fun `excess levels with mismatching length allowed`() { val format = NumberingFormat.fromString("1.1.a-A") assertEquals("1.2.c-D", format.format(1, 2, 3, 4, 5, 6)) } @Test fun `excess levels with mismatching length disallowed`() { val format = NumberingFormat.fromString("1.1.a-A") assertEquals("", format.format(1, 2, 3, 4, 5, 6, allowMismatchingLength = false)) } @Test fun `roman numeral formatting`() { val roman = NumberingFormat.fromString("I.i") assertEquals("III", roman.format(3)) assertEquals("I.i", roman.format(1, 1)) assertEquals("IV.iii", roman.format(4, 3)) assertEquals("XVII.lvii", roman.format(17, 57)) } @Test fun `roman numeral formatting with unallowed values`() { val roman = NumberingFormat.fromString("I.i") assertEquals("XVII.0", roman.format(17, 0)) assertEquals("0.50000", roman.format(0, 50000)) } @Test fun `trailing fixed symbol`() { val format = NumberingFormat.fromString("(1.1)") assertEquals("(2.3)", format.format(2, 3)) assertEquals("(1", format.format(1)) } @Test fun `escaped counting symbols as fixed`() { // `\1` is a literal "1", not a decimal counter. // `\1.1` -> fixed '1', fixed '.', decimal counter = 3 symbols. val format = NumberingFormat.fromString("\\1.1") assertEquals(3, format.symbols.size) assertIs<NumberingFixedSymbol>(format.symbols[0]) assertEquals('1', (format.symbols[0] as NumberingFixedSymbol).value) assertIs<NumberingFixedSymbol>(format.symbols[1]) assertIs<DecimalNumberingSymbol>(format.symbols[2]) assertEquals("1.2", format.format(2)) } @Test fun `escaped alpha and roman symbols`() { // All counting symbols escaped: no counters, all fixed. val format = NumberingFormat.fromString("\\A\\a\\I\\i") assertEquals(4, format.symbols.size) format.symbols.forEach { assertIs<NumberingFixedSymbol>(it) } assertEquals(0, format.accuracy) assertEquals("AaIi", format.format(1)) // Mix of escaped and non-escaped. val mixed = NumberingFormat.fromString("\\A-A") assertEquals(3, mixed.symbols.size) assertIs<NumberingFixedSymbol>(mixed.symbols[0]) assertIs<NumberingFixedSymbol>(mixed.symbols[1]) assertIs<AlphaNumberingSymbol>(mixed.symbols[2]) assertEquals("A-B", mixed.format(2)) } @Test fun `escaped backslash`() { // `\\` is a literal backslash fixed symbol, followed by a decimal counter. val format = NumberingFormat.fromString("\\\\1") assertEquals(2, format.symbols.size) assertEquals('\\', assertIs<NumberingFixedSymbol>(format.symbols[0]).value) assertIs<DecimalNumberingSymbol>(format.symbols[1]) assertEquals("\\3", format.format(3)) } @Test fun `trailing backslash is ignored`() { val format = NumberingFormat.fromString("1\\") assertEquals(1, format.symbols.size) assertIs<DecimalNumberingSymbol>(format.symbols[0]) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/NumberingTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.getLocation import com.quarkdown.core.ast.attributes.location.getLocationLabel import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.location.LocationAwareLabelStorerHook import com.quarkdown.core.context.hooks.location.LocationAwarenessHook import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.util.node.flattenedChildren import com.quarkdown.core.util.node.toPlainText import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for element numbering. */ class NumberingTest { private fun getLabels( tree: NestableNode, numbering: DocumentNumbering, ): List<String> { val context = MutableContext(QuarkdownFlavor) context.documentInfo = context.documentInfo.copy(numbering = numbering) ObservableAstIterator() .attach(LocationAwarenessHook(context)) .attach(LocationAwareLabelStorerHook(context)) .traverse(tree) return tree .flattenedChildren() .filterIsInstance<LocationTrackableNode>() .mapNotNull { it.getLocationLabel(context) } .toList() } @Test fun `heading numbering`() { val tree = buildBlock { root { heading(1) { text("1") } paragraph { text("...") } heading(2) { text("1.A") } paragraph { text("...") } heading(2) { text("1.B") } heading(3) { text("1.B.a") } heading(2) { text("1.C") } heading(4) { text("1.C.0.a") } blockQuote { heading(3) { text("1.C.a") } } heading(1) { text("2") } heading(1) { text("3") } } } as AstRoot val context = MutableContext(QuarkdownFlavor) ObservableAstIterator() .attach(LocationAwarenessHook(context)) .traverse(tree) assertEquals( mapOf( "1" to listOf(1), "1.A" to listOf(1, 1), "1.B" to listOf(1, 2), "1.B.a" to listOf(1, 2, 1), "1.C" to listOf(1, 3), "1.C.0.a" to listOf(1, 3, 0, 1), "1.C.a" to listOf(1, 3, 1), "2" to listOf(2), "3" to listOf(3), ), tree .flattenedChildren() .filterIsInstance<LocationTrackableNode>() .associateWith { it.getLocation(context)!! } .mapKeys { (node, _) -> (node as TextNode).text.toPlainText() } .mapValues { (_, location) -> location.levels }, ) } @Test fun `figure numbering`() { val tree = buildBlock { root { figure { image("img.png", title = "Caption") } heading(1) { text("1") } figure { image("img.png", title = "Caption") } heading(2) { text("1.A") } figure { image("img.png", title = "Caption") } figure { image("img.png", title = "Caption") } heading(2) { text("1.B") } figure { image("img.png", title = "Caption") } heading(1) { text("2") } figure { image("img.png", title = "Caption") } figure { image("img.png", title = "Caption") } } } as AstRoot val numbering = { format: String -> DocumentNumbering(figures = NumberingFormat.fromString(format)) } assertEquals( listOf("0.1", "1.1", "1.2", "1.3", "1.4", "2.1", "2.2"), getLabels(tree, numbering("1.1")), ) assertEquals( listOf("0.i", "1.i", "1.ii", "1.iii", "1.iv", "2.i", "2.ii"), getLabels(tree, numbering("1.i")), ) assertEquals( listOf("0.0.a", "1.0.a", "1.A.a", "1.A.b", "1.B.a", "2.0.a", "2.0.b"), getLabels(tree, numbering("1.A.a")), ) } @Test fun `code block numbering`() { val tree = buildBlock { root { +Code("Code block 1", language = "java") heading(1) { text("1") } +Code("Code block 2", language = "java") heading(2) { text("1.A") } +Code("Code block 3", language = "java") +Code("Code block 4", language = "java") heading(2) { text("1.B") } +Code("Code block 5", language = "java") heading(1) { text("2") } +Code("Code block 6", language = "java") +Code("Code block 7", language = "java") } } as AstRoot assertEquals( listOf("0.1", "1.1", "1.2", "1.3", "1.4", "2.1", "2.2"), getLabels(tree, DocumentNumbering(codeBlocks = NumberingFormat.fromString("1.1"))), ) } @Test fun `custom numbering`() { fun numbered(key: String) = Numbered(key) { location -> buildBlocks { paragraph { text("Hi from $location.") } } } val tree = buildBlock { root { +numbered("key1") heading(1) { text("1") } +numbered("key1") +numbered("key2") heading(2) { text("1.A") } +numbered("key1") heading(1) { text("2") } +numbered("key2") +numbered("key1") } } as AstRoot val labels = getLabels( tree, DocumentNumbering( extra = mapOf( "key1" to NumberingFormat.fromString("1.1"), "key2" to NumberingFormat.fromString("A"), ), ), ) assertEquals( listOf("0.1", "1.1", "A", "1.2", "B", "2.1"), labels, ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/PipelineStageTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.pipeline.stage.PipelineStage import com.quarkdown.core.pipeline.stage.SharedPipelineData import com.quarkdown.core.pipeline.stage.then import com.quarkdown.core.pipeline.stage.thenOptionally import kotlin.test.Test import kotlin.test.assertEquals private object DoubleNumberStage : PipelineStage<Int, Int> { override val hook = null override fun process( input: Int, data: SharedPipelineData, ): Int = input * 2 } private object ToStringStage : PipelineStage<Int, String> { override val hook = null override fun process( input: Int, data: SharedPipelineData, ): String = input.toString() } private const val INPUT = 3 private const val DOUBLED_ONCE = INPUT * 2 private const val DOUBLED_TWICE = DOUBLED_ONCE * 2 /** * Tests for the [PipelineStage] composition and processing. */ class PipelineStageTest { private val context = MutableContext(QuarkdownFlavor) private val pipeline = context.attachMockPipeline() private val data = SharedPipelineData(pipeline, context) @Test fun `stage processed once`() { val output = DoubleNumberStage.process(INPUT, data) assertEquals(DOUBLED_ONCE, output) } @Test fun `stage processed twice`() { val stage = DoubleNumberStage val firstOutput = stage.process(INPUT, data) val secondOutput = stage.process(firstOutput, data) assertEquals(DOUBLED_TWICE, secondOutput) } @Test fun `composed twice`() { val chain = DoubleNumberStage then DoubleNumberStage val output = chain.process(INPUT, data) assertEquals(DOUBLED_TWICE, output) } @Test fun `composed three times`() { val chain = DoubleNumberStage then DoubleNumberStage then DoubleNumberStage val output = chain.process(INPUT, data) assertEquals(24, output) } @Test fun `composed to different output type`() { val chain = DoubleNumberStage then ToStringStage val output = chain.process(INPUT, data) assertEquals("6", output) } @Test fun `composed optionally, not null`() { val chain = DoubleNumberStage thenOptionally DoubleNumberStage val output = chain.process(INPUT, data) assertEquals(DOUBLED_TWICE, output) } @Test fun `composed optionally, null`() { val chain = DoubleNumberStage thenOptionally null val output = chain.process(INPUT, data) assertEquals(DOUBLED_ONCE, output) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/PropertiesTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.attributes.MutableAstAttributes import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.property.MutablePropertyContainer import com.quarkdown.core.property.Property import kotlin.test.Test import kotlin.test.assertEquals /** * */ class PropertiesTest { private data class MyProperty( override val value: String, ) : Property<String> { companion object : Property.Key<String> override val key: Property.Key<String> = MyProperty } private val property = MyProperty("test") @Test fun properties() { val properties = MutablePropertyContainer<String>() properties += property val retrievedProperty: String? = properties[MyProperty] assertEquals(property.value, retrievedProperty) } @Test fun `node properties`() { val attributes = MutableAstAttributes() val node = Text("hello") attributes.properties.of(node) += property val retrievedProperty: String? = attributes.properties.of(node)[MyProperty] assertEquals(property.value, retrievedProperty) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/StandaloneFunctionTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.DocumentType import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.Function import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.SimpleFunction import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.call.binding.ArgumentBindings import com.quarkdown.core.function.call.validate.FunctionCallValidator import com.quarkdown.core.function.error.InvalidArgumentCountException import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.core.function.error.NoSuchElementException import com.quarkdown.core.function.error.ParameterAlreadyBoundException import com.quarkdown.core.function.error.UnnamedArgumentAfterNamedException import com.quarkdown.core.function.error.UnresolvedParameterException import com.quarkdown.core.function.expression.ComposedExpression import com.quarkdown.core.function.library.loader.MultiFunctionLibraryLoader import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.KFunctionAdapter import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.pipeline.error.PipelineException import kotlin.reflect.KFunction import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull /** * Function call tests. * For tests of function calls from Quarkdown sources see [FunctionNodeExpansionTest]. */ class StandaloneFunctionTest { /** * @param name name of the parameter to get the corresponding argument value for * @param T type of the value * @return the value of the argument by the given name * @throws NoSuchElementException if [name] does not match any parameter name */ private inline fun <reified T> ArgumentBindings.arg(name: String): T = this.entries .first { it.key.name == name } .value // Map.Entry method: returns FunctionCallArgument .value // FunctionCallArgument method: returns InputValue<T> .unwrappedValue as T // InputValue<T> method: returns T @Test fun `no arguments`() { val function = SimpleFunction( name = "greet", parameters = emptyList(), ) { _, call -> ValueFactory.string("Hello, ${call.function.name}") } val call = FunctionCall(function, arguments = emptyList()) assertEquals("Hello, greet", call.execute().unwrappedValue) } @Test fun `with arguments`() { val function = SimpleFunction( name = "greet", parameters = listOf( FunctionParameter("to", StringValue::class, index = 0), FunctionParameter("from", StringValue::class, index = 1), ), ) { bindings, _ -> val to = bindings.arg<String>("to") val from = bindings.arg<String>("from") ValueFactory.string("Hello $to from $from") } val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), FunctionCallArgument(StringValue("B")), ), ) assertEquals("Hello A from B", call.execute().unwrappedValue) } @Test fun `with nested call arguments`() { val functionPerson = SimpleFunction( name = "person", parameters = emptyList(), ) { _, _ -> ValueFactory.string("A") } val functionGreet = SimpleFunction( name = "greet", parameters = listOf( FunctionParameter("to", StringValue::class, index = 0), FunctionParameter("from", StringValue::class, index = 1), ), ) { bindings, _ -> val to = bindings.arg<String>("to") val from = bindings.arg<String>("from") ValueFactory.string("Hello $to from $from") } val callPerson = FunctionCall( functionPerson, arguments = emptyList(), ) val callGreet = FunctionCall( functionGreet, arguments = listOf( FunctionCallArgument(callPerson), FunctionCallArgument(StringValue("B")), ), ) assertEquals("Hello A from B", callGreet.execute().unwrappedValue) } @Test fun `with composed arguments`() { val functionPerson = SimpleFunction( name = "person", parameters = emptyList(), ) { _, _ -> ValueFactory.string("A") } val functionGreet = SimpleFunction( name = "greet", parameters = listOf( FunctionParameter("to", StringValue::class, index = 0), FunctionParameter("from", StringValue::class, index = 1), ), ) { bindings, _ -> val to = bindings.arg<String>("to") val from = bindings.arg<String>("from") ValueFactory.string("Hello $to from $from") } val callPerson = FunctionCall( functionPerson, arguments = emptyList(), ) val callGreet = FunctionCall( functionGreet, arguments = listOf( FunctionCallArgument(ComposedExpression(listOf(callPerson, StringValue("B")))), FunctionCallArgument(StringValue("B")), ), ) assertEquals("Hello AB from B", callGreet.execute().unwrappedValue) } @Test fun `with validator`() { var canCall = true val validator = object : FunctionCallValidator<StringValue> { override fun validate(call: FunctionCall<StringValue>) { if (!canCall) throw IllegalStateException() } } val function = SimpleFunction( name = "greet", parameters = emptyList(), validators = listOf(validator), ) { _, _ -> canCall = false ValueFactory.string("Hello") } val call = FunctionCall(function, arguments = emptyList()) assertEquals("Hello", call.execute().unwrappedValue) assertFailsWith<IllegalStateException> { call.execute() } } @Test fun `infinite recursion throws PipelineException`() { lateinit var recursiveCall: FunctionCall<StringValue> val function = SimpleFunction( name = "recurse", parameters = emptyList(), ) { _, _ -> // Calls itself infinitely. recursiveCall.execute() } recursiveCall = FunctionCall(function, arguments = emptyList()) val exception = assertFailsWith<PipelineException> { recursiveCall.execute() } assertIs<PipelineException>(exception) assert(exception.message!!.contains("Maximum function call depth")) } @Suppress("MemberVisibilityCanBePrivate") fun greetNoArgs(): StringValue = StringValue("Hello") @Test fun `KFunction without arguments`() { val function = KFunctionAdapter(::greetNoArgs) val call = FunctionCall(function, arguments = emptyList()) assertEquals("Hello", call.execute().unwrappedValue) } @Suppress("MemberVisibilityCanBePrivate") fun greetWithArgs( to: String, from: String, ): StringValue = StringValue("Hello $to from $from") @Test fun `KFunction with arguments`() { val function = KFunctionAdapter(::greetWithArgs) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), FunctionCallArgument(StringValue("B")), ), ) assertEquals("Hello A from B", call.execute().unwrappedValue) } @Test fun `KFunction with named arguments`() { val function = KFunctionAdapter(::greetWithArgs) val call1 = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A"), name = "to"), FunctionCallArgument(StringValue("B"), name = "from"), ), ) assertEquals("Hello A from B", call1.execute().unwrappedValue) val call2 = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A"), name = "from"), FunctionCallArgument(StringValue("B"), name = "to"), ), ) assertEquals("Hello B from A", call2.execute().unwrappedValue) val call3 = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), FunctionCallArgument(StringValue("B"), name = "from"), ), ) assertEquals("Hello A from B", call3.execute().unwrappedValue) val call4 = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A"), name = "to"), FunctionCallArgument(StringValue("B")), ), ) // Unnamed arguments cannot appear after a named argument. assertFailsWith<UnnamedArgumentAfterNamedException> { call4.execute() } val call5 = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), FunctionCallArgument(StringValue("B"), name = "other"), ), ) // Named reference to an unknown parameter. assertFailsWith<UnresolvedParameterException> { call5.execute() } } @Suppress("MemberVisibilityCanBePrivate") fun greetWithOptionalArgs( to: String = "you", from: String = "me", ): StringValue = StringValue("Hello $to from $from") @Test fun `KFunction with optional arguments`() { val function = KFunctionAdapter(::greetWithOptionalArgs) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), ), ) assertEquals("Hello A from me", call.execute().unwrappedValue) val callNamed = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A"), name = "from"), ), ) assertEquals("Hello you from A", callNamed.execute().unwrappedValue) } @Suppress("MemberVisibilityCanBePrivate") fun greetWithOptionalArgsInTheMiddle( to: String = "you", from: String = "me", content: String, ): StringValue = StringValue("Hello $to from $from: $content") @Test fun `KFunction with optional arguments in the middle`() { val function = KFunctionAdapter(::greetWithOptionalArgsInTheMiddle) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), FunctionCallArgument(StringValue("hi!"), isBody = true), ), ) assertEquals("Hello A from me: hi!", call.execute().unwrappedValue) val invalidCall = FunctionCall( function, arguments = listOf( FunctionCallArgument(StringValue("A")), // Not marking the argument as body will associate it to the second parameter instead. FunctionCallArgument(StringValue("hi!")), ), ) assertFailsWith<InvalidArgumentCountException> { invalidCall.execute() } } @Suppress("MemberVisibilityCanBePrivate") fun sum( a: Int, b: Int, ): NumberValue = NumberValue(a + b) @Test fun `KFunction with auto arguments`() { val function = KFunctionAdapter(::sum) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("2")), FunctionCallArgument(DynamicValue("5")), ), ) assertEquals(7, call.execute().unwrappedValue) } @Test fun `KFunction wrong argument count`() { val function = KFunctionAdapter(::sum) val call1 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("5")), ), ) assertFailsWith<InvalidArgumentCountException> { call1.execute() } val call2 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("5")), FunctionCallArgument(DynamicValue("1")), FunctionCallArgument(DynamicValue("2")), ), ) assertFailsWith<InvalidArgumentCountException> { call2.execute() } val call3 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("5")), FunctionCallArgument(DynamicValue("1")), ), ) assertEquals(6, call3.execute().unwrappedValue) } @Test fun `KFunction parameter bound twice`() { val function = KFunctionAdapter(::sum) val call1 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("5")), FunctionCallArgument(DynamicValue("2"), name = "a"), ), ) assertFailsWith<ParameterAlreadyBoundException> { call1.execute() } } @Test fun `KFunction wrong argument types`() { val function = KFunctionAdapter(::sum) val call1 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("a")), FunctionCallArgument(DynamicValue("b")), ), ) // Mismatching types assertFailsWith<InvalidFunctionCallException> { call1.execute() } val call2 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("5")), FunctionCallArgument(DynamicValue("abc")), ), ) assertFailsWith<InvalidFunctionCallException> { call2.execute() } val call3 = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("abcde")), FunctionCallArgument(DynamicValue("5")), ), ) assertFailsWith<InvalidFunctionCallException> { call3.execute() } } @Suppress("MemberVisibilityCanBePrivate") fun identity(x: Int) = NumberValue(x) @Test fun `KFunction with nested call arguments`() { val functionSum = KFunctionAdapter(::sum) val functionIdentity = KFunctionAdapter(::identity) val callIdentity = FunctionCall( functionIdentity, arguments = listOf( FunctionCallArgument(DynamicValue("2")), ), ) val callSum = FunctionCall( functionSum, arguments = listOf( FunctionCallArgument(NumberValue(3)), FunctionCallArgument(callIdentity), ), ) assertEquals(5, callSum.execute().unwrappedValue) } @Test fun `KFunction with composed arguments`() { val functionGreetWithArgs = KFunctionAdapter(::greetWithArgs) val functionGreetWithoutArgs = KFunctionAdapter(::greetNoArgs) val callWithoutArgs = FunctionCall( functionGreetWithoutArgs, arguments = emptyList(), ) val callWithArgs = FunctionCall( functionGreetWithArgs, arguments = listOf( FunctionCallArgument(ComposedExpression(listOf(callWithoutArgs, StringValue(" dear")))), FunctionCallArgument(StringValue("B")), ), ) assertEquals("Hello Hello dear from B", callWithArgs.execute().unwrappedValue) } @Test fun `KFunction with dynamic composed arguments`() { val functionGreetWithArgs = KFunctionAdapter(::greetWithArgs) val functionGreetWithoutArgs = KFunctionAdapter(::greetNoArgs) val callWithoutArgs = FunctionCall( functionGreetWithoutArgs, arguments = emptyList(), ) val callWithArgs = FunctionCall( functionGreetWithArgs, arguments = listOf( FunctionCallArgument(ComposedExpression(listOf(callWithoutArgs, DynamicValue(" dear")))), FunctionCallArgument(DynamicValue("B")), ), ) assertEquals("Hello Hello dear from B", callWithArgs.execute().unwrappedValue) } @Suppress("MemberVisibilityCanBePrivate") fun echoEnum(value: Container.Alignment) = StringValue(value.name) @Test fun `KFunction with enum`() { val function = KFunctionAdapter(::echoEnum) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("center")), ), ) assertEquals("CENTER", call.execute().unwrappedValue) } @Test fun `KFunction with invalid enum`() { val function = KFunctionAdapter(::echoEnum) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("something")), ), ) assertFailsWith<InvalidFunctionCallException> { call.execute() } } @Suppress("MemberVisibilityCanBePrivate") fun setDocumentName( @Injected context: MutableContext, name: String, ): VoidValue { context.documentInfo = context.documentInfo.copy(name = name) return VoidValue } @Test fun `KFunction with injected context`() { val function = KFunctionAdapter(::setDocumentName) val context = MutableContext(QuarkdownFlavor) val call = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("New name")), ), context, ) assertNull(context.documentInfo.name) call.execute() assertEquals("New name", context.documentInfo.name) } @Suppress("MemberVisibilityCanBePrivate") fun invalidInjection( @Injected x: String, ) = StringValue(x) @Test fun `KFunction with invalid injection`() { val function = KFunctionAdapter(::invalidInjection) val context = MutableContext(QuarkdownFlavor) val call = FunctionCall( function, arguments = emptyList(), context, ) assertNull(context.documentInfo.name) // String isn't an injectable type. assertFailsWith<IllegalArgumentException> { call.execute() } } private fun <T : OutputValue<*>> createCallForDocumentType( function: KFunction<T>, documentType: DocumentType, ): FunctionCall<T> { val context = MutableContext(QuarkdownFlavor) context.documentInfo = context.documentInfo.copy(type = documentType) val adapter = KFunctionAdapter(function) return FunctionCall(adapter, emptyList(), context) } @Suppress("MemberVisibilityCanBePrivate") @OnlyForDocumentType(DocumentType.SLIDES) fun slidesOnlyGreet(): StringValue = StringValue("Hello") @Test fun `KFunction with whitelisted document type`() { val call = createCallForDocumentType(::slidesOnlyGreet, DocumentType.SLIDES) assertEquals("Hello", call.execute().unwrappedValue) } @Test fun `KFunction with non-whitelisted document type`() { val call = createCallForDocumentType(::slidesOnlyGreet, DocumentType.PLAIN) assertFailsWith<InvalidFunctionCallException> { call.execute() } } @Suppress("MemberVisibilityCanBePrivate") @NotForDocumentType(DocumentType.SLIDES) fun allButSlidesGreet(): StringValue = StringValue("Hello") @Test fun `KFunction with non-blacklisted document type`() { val call = createCallForDocumentType(::allButSlidesGreet, DocumentType.PAGED) assertEquals("Hello", call.execute().unwrappedValue) } @Test fun `KFunction with blacklisted document type`() { val call = createCallForDocumentType(::allButSlidesGreet, DocumentType.SLIDES) assertFailsWith<InvalidFunctionCallException> { call.execute() } } @Test fun `library loader`() { val library = MultiFunctionLibraryLoader("MyLib").load(moduleOf(::greetWithArgs, ::greetNoArgs, ::sum)) assertEquals("MyLib", library.name) assertEquals(3, library.functions.size) val function = library.functions.first { it.name == "sum" } assertIs<Function<NumberValue>>(function) assertEquals(2, function.parameters.size) val staticCall = FunctionCall( function, arguments = listOf( FunctionCallArgument(NumberValue(2)), FunctionCallArgument(NumberValue(5)), ), ) assertEquals(7, staticCall.execute().unwrappedValue) val dynamicCall = FunctionCall( function, arguments = listOf( FunctionCallArgument(DynamicValue("2")), FunctionCallArgument(DynamicValue("5")), ), ) assertEquals(7, dynamicCall.execute().unwrappedValue) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/SubdocumentRegistrationTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.getSubdocument import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.hooks.SubdocumentRegistrationHook import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import kotlin.test.Test import kotlin.test.assertEquals private const val RESOURCE_PATH = "src/test/resources/subdoc" /** * Tests for subdocument registration from [SubdocumentLink]. */ class SubdocumentRegistrationTest { private val context = MutableContext(QuarkdownFlavor) private val link1 = SubdocumentLink( Link( label = buildInline { text("Link") }, url = "$RESOURCE_PATH/subdoc-1.qd", title = null, ), ) private val link2 = SubdocumentLink( Link( label = buildInline { text("Link") }, url = "$RESOURCE_PATH/subdoc-2.qd", title = null, ), ) private fun traverse(root: Node) { context.sharedSubdocumentsData = context.sharedSubdocumentsData.copy(graph = context.sharedSubdocumentsData.graph.addVertex(Subdocument.Root)) ObservableAstIterator() .attach(SubdocumentRegistrationHook(context)) .traverse(root as NestableNode) } @Test fun `root to 1`() { val root = buildBlock { root { +link1 } } traverse(root) assertEquals( 2, context.sharedSubdocumentsData.graph.vertices.size, ) assertEquals( link1.getSubdocument(context), context.sharedSubdocumentsData.graph .getNeighbors(Subdocument.Root) .single(), ) } @Test fun `root to 1 and 2`() { val root = buildBlock { root { +link1 +link2 } } traverse(root) assertEquals( 3, context.sharedSubdocumentsData.graph.vertices.size, ) assertEquals( 2, context.sharedSubdocumentsData.graph .getNeighbors(Subdocument.Root) .count(), ) } @Test fun `root to 1 twice`() { val root = buildBlock { root { +link1 +link1 } } traverse(root) assertEquals( 2, context.sharedSubdocumentsData.graph.vertices.size, ) assertEquals( 1, context.sharedSubdocumentsData.graph .getNeighbors(Subdocument.Root) .count(), ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/TableOfContentsTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.context.toc.TableOfContents import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for [TableOfContents] generation. */ class TableOfContentsTest { private fun heading( level: Int, text: String, ) = Heading(level, listOf(Text(text))) private fun generateToc(vararg headings: Heading) = TableOfContents.generate(headings.asSequence()) private fun assertHeadingText( expected: String, item: TableOfContents.Item, ) { assertNodeEquals(Text(expected), item.text.first()) } @Test fun `empty headings produce empty toc`() { val toc = generateToc() assertTrue(toc.items.isEmpty()) } @Test fun `single heading`() { val toc = generateToc(heading(1, "Title")) assertEquals(1, toc.items.size) assertHeadingText("Title", toc.items[0]) assertTrue(toc.items[0].subItems.isEmpty()) } @Test fun `multiple h1 headings are siblings`() { val toc = generateToc( heading(1, "First"), heading(1, "Second"), heading(1, "Third"), ) assertEquals(3, toc.items.size) assertHeadingText("First", toc.items[0]) assertHeadingText("Second", toc.items[1]) assertHeadingText("Third", toc.items[2]) } @Test fun `h2 is nested under h1`() { val toc = generateToc( heading(1, "Parent"), heading(2, "Child"), ) assertEquals(1, toc.items.size) assertEquals(1, toc.items[0].subItems.size) assertHeadingText("Parent", toc.items[0]) assertHeadingText("Child", toc.items[0].subItems[0]) } @Test fun `multiple h2 under same h1`() { val toc = generateToc( heading(1, "Parent"), heading(2, "Child 1"), heading(2, "Child 2"), heading(2, "Child 3"), ) assertEquals(1, toc.items.size) assertEquals(3, toc.items[0].subItems.size) assertHeadingText("Child 1", toc.items[0].subItems[0]) assertHeadingText("Child 2", toc.items[0].subItems[1]) assertHeadingText("Child 3", toc.items[0].subItems[2]) } @Test fun `h3 is nested under h2`() { val toc = generateToc( heading(1, "H1"), heading(2, "H2"), heading(3, "H3"), ) assertEquals(1, toc.items.size) assertEquals(1, toc.items[0].subItems.size) assertEquals( 1, toc.items[0] .subItems[0] .subItems.size, ) assertHeadingText("H3", toc.items[0].subItems[0].subItems[0]) } @Test fun `complex nested hierarchy`() { val toc = generateToc( heading(1, "ABC"), heading(2, "DEF"), heading(2, "GHI"), heading(3, "JKL"), heading(2, "MNO"), heading(1, "PQR"), ) assertEquals(2, toc.items.size) assertEquals(3, toc.items[0].subItems.size) assertEquals( 1, toc.items[0] .subItems[1] .subItems.size, ) assertHeadingText("ABC", toc.items[0]) assertHeadingText("DEF", toc.items[0].subItems[0]) assertHeadingText("GHI", toc.items[0].subItems[1]) assertHeadingText("JKL", toc.items[0].subItems[1].subItems[0]) assertHeadingText("MNO", toc.items[0].subItems[2]) assertHeadingText("PQR", toc.items[1]) } @Test fun `level skip - h1 to h3 directly`() { val toc = generateToc( heading(1, "ABC"), heading(3, "DEF"), heading(2, "GHI"), ) assertEquals(1, toc.items.size) assertEquals(2, toc.items[0].subItems.size) assertHeadingText("ABC", toc.items[0]) assertHeadingText("DEF", toc.items[0].subItems[0]) assertHeadingText("GHI", toc.items[0].subItems[1]) } @Test fun `new h1 resets nesting`() { val toc = generateToc( heading(1, "First Section"), heading(2, "Subsection"), heading(3, "Deep"), heading(1, "Second Section"), heading(2, "Another Subsection"), ) assertEquals(2, toc.items.size) assertEquals(1, toc.items[0].subItems.size) assertEquals( 1, toc.items[0] .subItems[0] .subItems.size, ) assertEquals(1, toc.items[1].subItems.size) assertHeadingText("First Section", toc.items[0]) assertHeadingText("Second Section", toc.items[1]) assertHeadingText("Another Subsection", toc.items[1].subItems[0]) } @Test fun `deep nesting up to h6`() { val toc = generateToc( heading(1, "Level 1"), heading(2, "Level 2"), heading(3, "Level 3"), heading(4, "Level 4"), heading(5, "Level 5"), heading(6, "Level 6"), ) assertEquals(1, toc.items.size) var current = toc.items[0] assertHeadingText("Level 1", current) for (level in 2..6) { assertEquals(1, current.subItems.size) current = current.subItems[0] assertHeadingText("Level $level", current) } assertTrue(current.subItems.isEmpty()) } @Test fun `alternating levels`() { val toc = generateToc( heading(1, "H1-A"), heading(2, "H2-A"), heading(1, "H1-B"), heading(2, "H2-B"), heading(1, "H1-C"), ) assertEquals(3, toc.items.size) assertEquals(1, toc.items[0].subItems.size) assertEquals(1, toc.items[1].subItems.size) assertEquals(0, toc.items[2].subItems.size) assertHeadingText("H1-A", toc.items[0]) assertHeadingText("H2-A", toc.items[0].subItems[0]) assertHeadingText("H1-B", toc.items[1]) assertHeadingText("H2-B", toc.items[1].subItems[0]) assertHeadingText("H1-C", toc.items[2]) } @Test fun `going back up multiple levels`() { val toc = generateToc( heading(1, "H1"), heading(2, "H2"), heading(3, "H3"), heading(4, "H4"), heading(2, "Back to H2"), ) assertEquals(1, toc.items.size) assertEquals(2, toc.items[0].subItems.size) assertHeadingText("H2", toc.items[0].subItems[0]) assertHeadingText("Back to H2", toc.items[0].subItems[1]) assertEquals( 1, toc.items[0] .subItems[0] .subItems.size, ) assertEquals( 0, toc.items[0] .subItems[1] .subItems.size, ) } @Test fun `headings starting at level 2`() { val toc = generateToc( heading(2, "ABC"), heading(3, "DEF"), heading(2, "GHI"), heading(1, "JKL"), ) assertEquals(3, toc.items.size) assertEquals(1, toc.items[0].subItems.size) assertHeadingText("ABC", toc.items[0]) assertHeadingText("DEF", toc.items[0].subItems[0]) assertHeadingText("GHI", toc.items[1]) assertHeadingText("JKL", toc.items[2]) } @Test fun `multiple branches at same level`() { val toc = generateToc( heading(1, "Root"), heading(2, "Branch A"), heading(3, "Leaf A1"), heading(3, "Leaf A2"), heading(2, "Branch B"), heading(3, "Leaf B1"), ) assertEquals(1, toc.items.size) assertEquals(2, toc.items[0].subItems.size) val branchA = toc.items[0].subItems[0] val branchB = toc.items[0].subItems[1] assertHeadingText("Branch A", branchA) assertEquals(2, branchA.subItems.size) assertHeadingText("Leaf A1", branchA.subItems[0]) assertHeadingText("Leaf A2", branchA.subItems[1]) assertHeadingText("Branch B", branchB) assertEquals(1, branchB.subItems.size) assertHeadingText("Leaf B1", branchB.subItems[0]) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/TemplateProcessorTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.template.TemplateProcessor import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [TemplateProcessor]. */ class TemplateProcessorTest { @Test fun `no values`() { val template = TemplateProcessor("Hello, world!") assertEquals("Hello, world!", template.process()) } @Test fun empty() { val template = TemplateProcessor("") assertEquals("", template.process()) } @Test fun `single value`() { val template = TemplateProcessor( """ @param String name Hello, ${'$'}{name}! """.trimIndent(), ) template.value("name", "world") assertEquals("Hello, world!", template.process()) } @Test fun `double value`() { val template = TemplateProcessor( """ @param String name @param String from Hello, ${'$'}{name} from ${'$'}{from}! """.trimIndent(), ) template.value("name", "world") template.value("from", "Quarkdown") assertEquals("Hello, world from Quarkdown!", template.process()) } @Test fun `single value with default`() { val template = TemplateProcessor( """ @param String name = "unnamed" Hello, ${'$'}{name}! """.trimIndent(), ) assertEquals("Hello, unnamed!", template.process()) } @Test fun `single condition`() { val trueTemplate = TemplateProcessor( """ @param boolean hasName = false Hello@if(hasName), world@endif! """.trimIndent(), ) trueTemplate.conditional("hasName", false) assertEquals("Hello!", trueTemplate.process()) trueTemplate.conditional("hasName", true) assertEquals("Hello, world!", trueTemplate.process()) } @Test fun `double condition`() { val template = TemplateProcessor( """ @param boolean hasName = false @param boolean ask = false Hello@if(hasName), world@endif!@if(ask) How are you?@endif """.trimIndent(), ) template.conditional("hasName", false) template.conditional("ask", false) assertEquals("Hello!", template.process()) template.conditional("ask", true) assertEquals("Hello! How are you?", template.process()) template.conditional("hasName", true) assertEquals("Hello, world! How are you?", template.process()) template.conditional("ask", false) assertEquals("Hello, world!", template.process()) } @Test fun `single value and single condition`() { val template = TemplateProcessor( """ @param String name @param boolean ask = false Hello, ${'$'}{name}!@if(ask) How are you?@endif """.trimIndent(), ) template.value("name", "world") template.conditional("ask", false) assertEquals("Hello, world!", template.process()) template.conditional("ask", true) assertEquals("Hello, world! How are you?", template.process()) } @Test fun `optional value as condition`() { val template = TemplateProcessor( """ @param String name = null Hello@if(name != null), ${'$'}{name}@endif! """.trimIndent(), ) template.optionalValue("name", "world") assertEquals("Hello, world!", template.process()) template.optionalValue("name", null) assertEquals("Hello!", template.process()) } @Test fun `optional value if-else`() { val template = TemplateProcessor( """ @param String name = null Hello, @if(name != null)${'$'}{name}@endif@if(name == null)unnamed@endif! """.trimIndent(), ) template.optionalValue("name", "world") assertEquals("Hello, world!", template.process()) template.optionalValue("name", null) assertEquals("Hello, unnamed!", template.process()) } @Test fun multiline() { val template = TemplateProcessor( """ @param String name @param boolean ask = false Hello, ${'$'}{name}! @if(ask) How are you? I hope you are good. @endif """.trimIndent(), ) template.value("name", "world") template.conditional("ask", true) assertEquals( """ Hello, world! How are you? I hope you are good. """.trimIndent(), template.process(), ) template.conditional("ask", false) assertEquals("Hello, world!", template.process()) } @Test fun `multiline with delimiter`() { val template = TemplateProcessor( """ @param boolean a = false @param boolean b = false @if(a) A @endif X @if(b) B @endif """.trimIndent(), ) template.conditional("a", true) template.conditional("b", false) assertEquals("A\nX", template.process()) template.conditional("a", false) template.conditional("b", false) assertEquals("X", template.process()) template.conditional("a", false) template.conditional("b", true) assertEquals("X\nB", template.process()) } @Test fun `multiline with spaced delimiter`() { // Blank lines act as separators between content sections. // They are placed inside the conditional blocks to control their visibility. val template = TemplateProcessor( """ @param boolean a = false @param boolean b = false @if(a) A @endif X @if(b) B @endif """.trimIndent(), ) template.conditional("a", true) template.conditional("b", false) assertEquals("A\nX", template.process()) template.conditional("a", false) template.conditional("b", false) assertEquals("X", template.process()) template.conditional("a", false) template.conditional("b", true) assertEquals("X\n\nB", template.process()) } @Test fun `multiline with gap`() { val template = TemplateProcessor( """ @param boolean a = false @param boolean b = false @param boolean c = false @if(a) A @endif @if(b) B @endif @if(c) C @endif """.trimIndent(), ) template.conditional("a", true) template.conditional("b", false) template.conditional("c", true) assertEquals("A\nC", template.process()) } @Test fun `from resource`() { val template = TemplateProcessor.fromResourceName("/template/template.jte") template.optionalValue("name", "world") template.conditional("ask", true) assertEquals( """ Hello, world! How are you? I'm good. """.trimIndent(), template.process(), ) } @Test fun `for each with no delimiters`() { val template = TemplateProcessor( """ @param java.util.List<String> names = null Hello, @for(String name : names)${'$'}{name}@endfor! """.trimIndent(), ) template.iterable("names", listOf("Alice", "Bob", "Charlie")) assertEquals("Hello, AliceBobCharlie!", template.process()) } @Test fun `for each with delimiters`() { val template = TemplateProcessor( """ @param java.util.List<String> names = null Hello, @for(String name : names)${'$'}{name},@endfor! """.trimIndent(), ) template.iterable("names", listOf("Alice", "Bob", "Charlie")) assertEquals("Hello, Alice,Bob,Charlie,!", template.process()) } @Test fun `multiline for each`() { val template = TemplateProcessor( """ @param java.util.List<String> items = null Groceries: @for(String item : items) - ${'$'}{item} @endfor """.trimIndent(), ) template.iterable("items", listOf("Apples", "Bananas", "Carrots")) assertEquals( """ Groceries: - Apples - Bananas - Carrots """.trimIndent(), template.process(), ) } @Test fun `empty for each`() { val template = TemplateProcessor( """ @param java.util.List<String> items = null Groceries: @for(String item : items) - ${'$'}{item} @endfor """.trimIndent(), ) template.iterable("items", emptyList()) assertEquals("Groceries:", template.process()) } @Test fun `nested for each`() { val template = TemplateProcessor( """ @param java.util.List<String> items = null @param java.util.List<String> letters = null Groceries: @for(String item : items) @for(String letter : letters) - ${'$'}{item} ${'$'}{letter} @endfor @endfor """.trimIndent(), ) template.iterable("items", listOf("Apple", "Banana")) template.iterable("letters", listOf("A", "B")) assertEquals( """ Groceries: - Apple A - Apple B - Banana A - Banana B """.trimIndent(), template.process(), ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/TreeTraversalTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.iterator.AstIteratorHook import com.quarkdown.core.ast.iterator.ObservableAstIterator import com.quarkdown.core.util.node.flattenedChildren import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue /** * Tree traversal tests. */ class TreeTraversalTest { @Test fun `tree visit`() { val node = AstRoot( listOf( BlockQuote( children = listOf( Paragraph(listOf(Text("abc"))), ), ), Paragraph( listOf( Strong(listOf(Text("abc"))), Text("def"), Emphasis(listOf(Text("ghi"))), ), ), Code("Hello, world!", language = "java"), ), ) with(node.flattenedChildren().map { it::class.simpleName }.toList()) { assertEquals( listOf( "BlockQuote", "Paragraph", "Text", "Paragraph", "Strong", "Text", "Text", "Emphasis", "Text", "Code", ), this, ) } // Iterator val blockQuoteHook = object : AstIteratorHook { override fun attach(iterator: ObservableAstIterator) { iterator.on<BlockQuote> { assertIs<Paragraph>(it.children.first()) } } } var finished = false ObservableAstIterator() .on<Strong> { assertNodeEquals(Text("abc"), it.children.first()) } .on<Emphasis> { assertNodeEquals(Text("ghi"), it.children.first()) } .attach(blockQuoteHook) .on<Code> { assertEquals("Hello, world!", it.content) assertEquals("java", it.language) }.onFinished { finished = true } .traverse(node) assertTrue(finished) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/ValueFactoryTest.kt ================================================ package com.quarkdown.core import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.document.size.cm import com.quarkdown.core.document.size.mm import com.quarkdown.core.document.size.px import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.error.InvalidLambdaArgumentCountException import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.LambdaValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.factory.IllegalRawValueException import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.misc.color.Color import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull /** * Tests of retrieval of value wrappers from raw strings. */ class ValueFactoryTest { private fun newContext() = MutableContext(QuarkdownFlavor).apply { attachMockPipeline() } @Test fun string() { assertEquals(StringValue("Hello, world!"), ValueFactory.string("Hello, world!")) } @Test fun number() { assertEquals(NumberValue(42), ValueFactory.number("42")) assertEquals(16.3F, ValueFactory.number("16.3").unwrappedValue) assertFails { ValueFactory.number("num") } assertFails { ValueFactory.number("16.3.2") } } @Test fun boolean() { assertEquals(BooleanValue(false), ValueFactory.boolean("false")) assertEquals(BooleanValue(false), ValueFactory.boolean("no")) assertEquals(BooleanValue(true), ValueFactory.boolean("true")) assertEquals(BooleanValue(true), ValueFactory.boolean("yes")) assertFails { ValueFactory.boolean("y") } } @Test fun range() { assertEquals(Range(1, 9), ValueFactory.range("1..9").unwrappedValue) assertEquals(Range(null, 11), ValueFactory.range("..11").unwrappedValue) assertEquals(Range(14, null), ValueFactory.range("14..").unwrappedValue) assertEquals(Range(null, null), ValueFactory.range("..").unwrappedValue) } @Test fun size() { assertEquals(Size(10.0, Size.Unit.PIXELS), ValueFactory.size("10px").unwrappedValue) assertEquals(Size(8.0, Size.Unit.POINTS), ValueFactory.size("8pt").unwrappedValue) assertEquals(Size(16.2, Size.Unit.CENTIMETERS), ValueFactory.size("16.2cm").unwrappedValue) assertEquals(Size(-16.2, Size.Unit.CENTIMETERS), ValueFactory.size("-16.2cm").unwrappedValue) assertEquals(Size(1.4, Size.Unit.MILLIMETERS), ValueFactory.size("1.4mm").unwrappedValue) assertEquals(Size(8.2, Size.Unit.INCHES), ValueFactory.size("8.2in").unwrappedValue) assertEquals(Size(20.2, Size.Unit.PERCENTAGE), ValueFactory.size("20.2%").unwrappedValue) assertEquals(Size(32.95, Size.Unit.PIXELS), ValueFactory.size("32.95").unwrappedValue) assertEquals(32.95.px, ValueFactory.size("32.95").unwrappedValue) assertFails { ValueFactory.size("px") } assertFails { ValueFactory.size("abc") } assertFails { ValueFactory.size("3abc") } assertFails { ValueFactory.size("10.10.2cm") } assertFails { ValueFactory.size("--10cm") } assertFails { ValueFactory.size("") } } @Test fun sizes() { assertEquals( Sizes( Size(10.0, Size.Unit.PIXELS), Size(10.0, Size.Unit.PIXELS), Size(10.0, Size.Unit.PIXELS), Size(10.0, Size.Unit.PIXELS), ), ValueFactory.sizes("10px").unwrappedValue, ) assertEquals( Sizes(all = Size(10.0, Size.Unit.PIXELS)), ValueFactory.sizes("10px").unwrappedValue, ) assertEquals( Sizes(all = Size(13.2, Size.Unit.CENTIMETERS)), ValueFactory.sizes("13.2cm").unwrappedValue, ) assertEquals( Sizes( 9.2.cm, 3.8.mm, 9.2.cm, 3.8.mm, ), ValueFactory.sizes("9.2cm 3.8mm").unwrappedValue, ) assertEquals( Sizes( vertical = Size(9.2, Size.Unit.CENTIMETERS), horizontal = Size(3.8, Size.Unit.MILLIMETERS), ), ValueFactory.sizes("9.2cm 3.8mm").unwrappedValue, ) assertEquals( Sizes( vertical = Size(9.2, Size.Unit.PERCENTAGE), horizontal = Size(20.0, Size.Unit.PERCENTAGE), ), ValueFactory.sizes("9.2% 20.0%").unwrappedValue, ) assertFails { ValueFactory.sizes("10px 12px 8px") } assertFails { ValueFactory.sizes("2xy") } } @Test fun color() { assertEquals(Color(255, 0, 0), ValueFactory.color("#FF0000").unwrappedValue) assertEquals(Color(255, 0, 0), ValueFactory.color("red").unwrappedValue) assertEquals(Color(0, 0, 0), ValueFactory.color("#000000").unwrappedValue) assertEquals(Color(0, 0, 0), ValueFactory.color("BLACK").unwrappedValue) assertEquals(Color(255, 99, 71), ValueFactory.color("ToMaTo").unwrappedValue) assertEquals(Color(145, 168, 50), ValueFactory.color("#91a832").unwrappedValue) assertEquals(Color(145, 168, 50), ValueFactory.color("rgb(145, 168, 50)").unwrappedValue) assertEquals(Color(120, 111, 93), ValueFactory.color("rgb(120,111,93)").unwrappedValue) assertEquals(Color(120, 111, 93, 0.5), ValueFactory.color("rgba(120, 111, 93, 0.5)").unwrappedValue) assertEquals(Color(50, 113, 168), ValueFactory.color("hsv(208, 70, 66)").unwrappedValue) assertEquals(Color(50, 113, 168), ValueFactory.color("hsv(568, 70, 66)").unwrappedValue) assertEquals(Color(50, 113, 168), ValueFactory.color("hsl(208, 54, 43)").unwrappedValue) assertFailsWith<IllegalRawValueException> { ValueFactory.color("abc") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("#hello") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgb(300, 200, 200)") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgb(300, 200, 200, 0.8)") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("rgba(100, 200, 200, 1.5)") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsl(120, 105, 20)") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsv(120, 10,200)") } assertFailsWith<IllegalRawValueException> { ValueFactory.color("hsv(20, 10, 50, 10)") } } @Test fun enum() { @Suppress("UNCHECKED_CAST") val values = Size.Unit.entries.toTypedArray() as Array<Enum<*>> assertEquals(Size.Unit.PIXELS, ValueFactory.enum("pixels", values)!!.unwrappedValue) assertEquals(Size.Unit.CENTIMETERS, ValueFactory.enum("centimeters", values)!!.unwrappedValue) assertEquals(Size.Unit.MILLIMETERS, ValueFactory.enum("milliMeTers", values)!!.unwrappedValue) assertNull(ValueFactory.enum("abc", values)) } @Test fun `no arguments lambda`() { with(ValueFactory.lambda("hello", newContext())) { assertIs<LambdaValue>(this) assertEquals("hello", unwrappedValue.invoke<String, StringValue>().unwrappedValue) } } @Test fun `two implicit arguments lambda`() { assertEquals( "hello world from iamgio", ValueFactory .lambda("hello .1 from .2", newContext()) .unwrappedValue .invoke<String, StringValue>( StringValue("world"), StringValue("iamgio"), ).unwrappedValue, ) } @Test fun `two explicit arguments lambda`() { assertEquals( "hello world from iamgio", ValueFactory .lambda( "to from: hello .to from .from", newContext(), ).unwrappedValue .invoke<String, StringValue>( StringValue("world"), StringValue("iamgio"), ).unwrappedValue, ) } @Test fun `present optional parameter lambda`() { assertEquals( "hello world from iamgio", ValueFactory .lambda( "to?: hello .to from iamgio", newContext(), ).unwrappedValue .invoke<String, StringValue>( StringValue("world"), ).unwrappedValue, ) } @Test fun `unpassed optional parameter lambda`() { assertEquals( "hello None from iamgio", ValueFactory .lambda( "to?: hello .to from iamgio", newContext(), ).unwrappedValue .invoke<String, StringValue>() .unwrappedValue, ) } @Test fun `one-passed two optional parameters lambda`() { assertEquals( "hello world from None", ValueFactory .lambda( "to from?: hello .to from .from", newContext(), ).unwrappedValue .invoke<String, StringValue>( StringValue("world"), ).unwrappedValue, ) } @Test fun `unallowed mixing of explicit and implicit arguments in lambda`() { assertFailsWith<InvalidLambdaArgumentCountException> { ValueFactory .lambda("to: hello .to from .2", newContext()) .unwrappedValue .invoke<String, StringValue>( StringValue("world"), StringValue("iamgio"), ).unwrappedValue } } @Test fun `simple iterable`() { assertEquals( listOf(DynamicValue("1"), DynamicValue("2"), DynamicValue("3")), ValueFactory .iterable( """ - 1 - 2 - 3 """.trimIndent(), newContext(), ).unwrappedValue, ) } @Test fun `nested iterable, compact`() { assertEquals( listOf( OrderedCollectionValue( listOf(DynamicValue("11"), DynamicValue("12")), ), OrderedCollectionValue( listOf(DynamicValue("22")), ), ), ValueFactory .iterable( """ - - 11 - 12 - - 22 """.trimIndent(), newContext(), ).unwrappedValue, ) } private val complexIterableResult = listOf( OrderedCollectionValue( listOf( DynamicValue("11"), DynamicValue("12"), OrderedCollectionValue( listOf( DynamicValue("121"), DynamicValue("122"), ), ), ), ), OrderedCollectionValue( listOf( OrderedCollectionValue( listOf(DynamicValue("211")), ), DynamicValue("22"), ), ), ) @Test fun `complex nested iterable, compact`() { assertEquals( complexIterableResult, ValueFactory .iterable( """ - - 11 - 12 - - 121 - 122 - - - 211 - 22 """.trimIndent(), newContext(), ).unwrappedValue, ) } @Test fun `complex nested iterable, extended syntax`() { assertEquals( complexIterableResult, ValueFactory .iterable( """ - : - 11 - 12 - : - 121 - 122 - : - : - 211 - 22 """.trimIndent(), newContext(), ).unwrappedValue, ) } @Test fun `simple dictionary`() { assertEquals( DictionaryValue( mutableMapOf( "abc" to DynamicValue("1"), "def" to DynamicValue("2"), "ghi" to DynamicValue("3"), ), ), ValueFactory.dictionary( """ - abc: 1 - def: 2 - ghi: 3 """.trimIndent(), newContext(), ), ) } @Test fun `nested dictionary`() { assertEquals( DictionaryValue( mutableMapOf( "abc" to DictionaryValue( mutableMapOf( "def" to DynamicValue("1"), "ghi" to DynamicValue("2"), ), ), ), ), ValueFactory.dictionary( """ - abc - def: 1 - ghi: 2 """.trimIndent(), newContext(), ), ) } @Test fun `complex nested dictionary`() { assertEquals( DictionaryValue( mutableMapOf( "a" to DynamicValue("1"), "b" to DictionaryValue( mutableMapOf( "c" to DynamicValue("2"), "d" to DynamicValue("3"), ), ), "e" to DynamicValue("4"), "f" to DictionaryValue( mutableMapOf( "g" to DictionaryValue( mutableMapOf( "h" to DynamicValue("5"), "i" to DictionaryValue( mutableMapOf( "j" to DynamicValue("6"), ), ), ), ), "k" to DynamicValue("7"), ), ), "l" to DynamicValue("8"), ), ), ValueFactory.dictionary( """ - a: 1 - b - c: 2 - d: 3 - e: 4 - f - g: - h: 5 - i - j: 6 - k: 7 - l: 8 """.trimIndent(), newContext(), ), ) } } ================================================ FILE: quarkdown-core/src/test/kotlin/com/quarkdown/core/util/ScopedCounterTest.kt ================================================ package com.quarkdown.core.util import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith class ScopedCounterTest { @Test fun `increments and decrements around block`() { val counter = ScopedCounter(maxDepth = 10) { error("overflow") } assertEquals(0, counter.get()) counter.incrementScoped { assertEquals(1, counter.get()) counter.incrementScoped { assertEquals(2, counter.get()) } assertEquals(1, counter.get()) } assertEquals(0, counter.get()) } @Test fun `resets on exception`() { val counter = ScopedCounter(maxDepth = 10) { error("overflow") } runCatching { counter.incrementScoped { counter.incrementScoped { throw RuntimeException("inner failure") } } } assertEquals(0, counter.get()) } @Test fun `overflows at max depth`() { val counter = ScopedCounter(maxDepth = 3) { throw IllegalStateException("overflow") } assertFailsWith<IllegalStateException> { counter.incrementScoped { counter.incrementScoped { counter.incrementScoped { counter.incrementScoped { // This 4th level should trigger overflow. } } } } } } @Test fun `resets after overflow`() { val counter = ScopedCounter(maxDepth = 2) { throw IllegalStateException("overflow") } assertFailsWith<IllegalStateException> { counter.incrementScoped { counter.incrementScoped { counter.incrementScoped {} } } } assertEquals(0, counter.get()) // Counter should be usable again after overflow. counter.incrementScoped { counter.incrementScoped {} } } @Test fun `sequential calls do not accumulate`() { val counter = ScopedCounter(maxDepth = 1) { throw IllegalStateException("overflow") } // Each call enters and exits — depth never exceeds 1. repeat(100) { counter.incrementScoped {} } assertEquals(0, counter.get()) } @Test fun `returns block result`() { val counter = ScopedCounter(maxDepth = 10) { error("overflow") } val result = counter.incrementScoped { 42 } assertEquals(42, result) } } ================================================ FILE: quarkdown-core/src/test/resources/bib/article.bib ================================================ @article{angenendt, author = {Angenendt, Arnold}, title = {In Honore Salvatoris~-- Vom Sinn und Unsinn der Patrozinienkunde}, journal = {Revue d'Histoire Ecclésiastique}, year = 2002, volume = 97, pages = {431--456, 791--823}, publisher = {Institut de Recherches Historiques du Septentrion}, langid = {german}, indextitle = {In Honore Salvatoris}, shorttitle = {In Honore Salvatoris}, annotation = {A German article in a French journal. Apart from that, a typical \texttt{article} entry. Note the \texttt{indextitle} field}, } ================================================ FILE: quarkdown-core/src/test/resources/bib/bibliography.bib ================================================ @article{einstein, author = "Albert Einstein", title = "Zur Elektrodynamik bewegter Körper. (German) [On the electrodynamics of moving bodies]", journal = "Annalen der Physik", volume = "322", number = "10", pages = "891--921", year = "1905", DOI = "http://dx.doi.org/10.1002/andp.19053221004" } @book{latexcompanion, author = "Michel Goossens and Frank Mittelbach and Alexander Samarin", title = "The LaTeX Companion", year = "1993", publisher = "Addison-Wesley", address = "Reading, Massachusetts" } @misc{knuthwebsite, author = "Donald Knuth", title = "Knuth: Computers and Typesetting", url = "http://www-cs-faculty.stanford.edu/\~uno/abcde.html" } ================================================ FILE: quarkdown-core/src/test/resources/bib/book.bib ================================================ @book{averroes/hannes, author = {Averroes}, title = {Des Averroes Abhandlung: Uber die Moglichkeit der Conjunktion oder Uber den materiellen Intellekt}, year = 1892, editor = {Hannes, Ludwig}, translator = {Hannes, Ludwig}, annotator = {Hannes, Ludwig}, publisher = {C.~A. Kaemmerer}, address = {Halle an der Saale}, edition = {1}, volume = {1}, keywords = {primary}, langid = {german}, sorttitle = {Uber die Moglichkeit der Conjunktion}, indexsorttitle = {Uber die Moglichkeit der Conjunktion}, indextitle = {Über die Möglichkeit der Conjunktion}, annotation = {An annotated edition}, } ================================================ FILE: quarkdown-core/src/test/resources/bib/misc.bib ================================================ @misc{knuthwebsite, author = "Donald Knuth", title = "Knuth: Computers and Typesetting", url = "http://www-cs-faculty.stanford.edu/\~uno/abcde.html" } ================================================ FILE: quarkdown-core/src/test/resources/bib/online.bib ================================================ @online{baez/online, author = {Baez, John C. and Lauda, Aaron D.}, title = {Higher-Dimensional Algebra {V}: 2-Groups}, year = {2004}, version = 3, langid = {english}, langidopts = {variant=american}, eprinttype = {arxiv}, eprint = {math/0307200v3}, annotation = {An online reference from arXiv}, url = {https://arxiv.org/abs/math/0307200v3}, } ================================================ FILE: quarkdown-core/src/test/resources/function/hello.txt ================================================ Hello Quarkdown! ================================================ FILE: quarkdown-core/src/test/resources/lexing/blocks.md ================================================ # Heading Paragraph 1 ## Heading Paragraph 2 Setext heading ----- Paragraph... some text. - A Inner content - B * A * B 1. First 2. Second > Blockquote >> Double blockquote > Blockquote >> Inner quote > Blockquote with lazy line Code (indentation) ```markdown Code (fences) ``` $$$ Math expression $$$ $ Math expression $ <<< --- [link]: url "Title multiline" *** [^footnote1]: one line [^footnote2]: multi line | foo | bar | |-----|-----| | abc | def | .blockfunction {arg1} {arg2} Paragraph .inlinefunction {arg1} - A - B .inlinefunction {arg1} {arg2} {arg3} .blockfunction {arg1} {arg2} {arg3} .inlinefunction {arg1} {arg2} text text .inlinefunction text .blockfunction {arg1} body body body end ================================================ FILE: quarkdown-core/src/test/resources/lexing/comment.md ================================================ text <!-- comment --> some text <!-- comment --> text <!--- comment --> text <!-- comment --> text <!-- comment --> text ================================================ FILE: quarkdown-core/src/test/resources/lexing/emphasis.md ================================================ **text** --- __text__ --- *text* --- _text_ --- (__text)__ --- abc **strong abc** text **test** aaaa *emphasis* --- abc __strong abc__ text __test__ aaaa _emphasis_ --- text **strong*x*m** text**strong** ** not strong** --- text __strong_x_m__ text__not strong__ __ not strong__ --- **strong *strongemphasis*** --- ***strongemphasis*** --- foo***bar***baz --- **foo__ --- *foo _bar* baz_ --- abc **def*g __h__ i*jkl** mno ================================================ FILE: quarkdown-core/src/test/resources/lexing/entity.md ================================================ &#35; &#1234; &#992;&#0; &#X22; &#XD06; &#xcab; &nbsp;&amp;&copy; &AElig; ================================================ FILE: quarkdown-core/src/test/resources/lexing/escape.md ================================================ Text \# text \m \! \.\, text ================================================ FILE: quarkdown-core/src/test/resources/lexing/inline.md ================================================ Text \! text `code` ``code`` ``not code` [title](label) newline not newline **bold** *italic* ***bold & italic*** text <https://google.com>https://google.com [label](https://google.com) [label](https://google.com 'url') [label][reference] [reference][] [reference] ![img](https://google.com) ![img] <!-- comm ent --> a **b c** [a*]* text $ math $ text .function {arg1} {arg2} x ================================================ FILE: quarkdown-core/src/test/resources/lexing/inlinefunction.md ================================================ .func {arg1} {arg2} text text .func {arg1} {arg2} text .func {arg1}{arg2} text text text .func {arg1} {arg2} text text .func{arg1} text text text text .func {arg1} {arg2} {arg3} text . func text text . func {arg1} .__notfunction__ {arg1} {arg2} text .fun {arg1} {arg2} .func {arg1} text ================================================ FILE: quarkdown-core/src/test/resources/lexing/linebreak.md ================================================ line break not line break not line break line break line break ================================================ FILE: quarkdown-core/src/test/resources/lexing/textreplacement.md ================================================ Hi... this is some text lexed by the Quarkdown (C) flavor--the Turing-complete Markdown -- which is iamgio's project. 3-1 is 2, which is not 4 => 2 != 4 -> also, 4 <= 8, and 8 >= 4 <- interesting - I should do something about it... 'Quarkdown's' source is available on 'GitHub'! "Stars" are much appreciated (TM) This is a "test". ================================================ FILE: quarkdown-core/src/test/resources/parsing/blockcode.md ================================================ Code line 1 Code line 2 Code line 3 ================================================ FILE: quarkdown-core/src/test/resources/parsing/blockquote.md ================================================ > Text > Text > Line 1 > Line 2 > Paragraph 1 > > Paragraph 2 > Text >> Inner quote > Text with lazy line > Text >> Inner text with lazy > lines > Text # Heading > Text ``` Code ``` > Text > 1. A > 2. B > Note: A note. > [!NOTE] > A note. > Tip: This is a tip! > - A > - B > [!TIP] > This is a tip! > - A > - B > Warning: you should be > more careful. > [!WARNING] > you should be > more careful. > Something: not a typed quote. > [!SOMETHING] > not a typed quote. > To be, or not to be, that is the question. > - William Shakespeare, Hamlet > Shopping list > - Water > - Pasta >> You miss 100% of the shots you don't take. >> - Wayne Gretzky > - _Michael Scott_ > Tip: Try Quarkdown. > - iamgio ================================================ FILE: quarkdown-core/src/test/resources/parsing/fencescode.md ================================================ ``` Code ``` ``` Code ``` ``` Code line 1 Code line 2 ``` ``` Code line 1 Code line 2 ``` ``` Code line 1 Code line 2 Code line 3 Code line 4 ``` ```text Code ``` ``` text Code ``` ```text Code line 1 Code line 2 ``` ```text Code line 1 Code line 2 ``` ```ecmascript 6 let x; ``` ```text "The caption" Code line 1 Code line 2 ``` ```text {#custom-id} Code line 1 Code line 2 ``` ``` text {#custom-id} Code line 1 Code line 2 ``` ```{#custom-id} Code line 1 Code line 2 ``` ```text "The caption" {#custom-id} Code line 1 Code line 2 ``` ================================================ FILE: quarkdown-core/src/test/resources/parsing/figure.md ================================================ ![Label](/url) ![Label](/url "Title") !(150x100)[Label](/url) !(150x_)[Label](/url) !(_x100)[Label](/url) !(1cm*2.1in)[Label](/url) !(100 50mm)[Label](/url) !(_*10px)[Label](/url) ![Label](/url) {#custom-id} ================================================ FILE: quarkdown-core/src/test/resources/parsing/footnotedefinition.md ================================================ [^1]: Footnote on *one line*. [^*Footnote*]: Footnote on one line. [^2]: Footnote on two lines. [^3]: Footnote on indented lines. [^int]: Interrupted footnote > Test [^int]: Interrupted footnote # Test ================================================ FILE: quarkdown-core/src/test/resources/parsing/functioncall-chain.md ================================================ .foo::bar {x} .foo {x}::bar name:{y} .foo {x}::bar {y}::baz {z} ================================================ FILE: quarkdown-core/src/test/resources/parsing/functioncall.md ================================================ .function .function {arg1} {arg2} .function {arg1\}} .function \{arg1} .function body content .function body content .function body content body **content** not body .function body content body content not body .function body content body content body content .function {arg1} {arg2} {arg3} body content body content body content not body .function {{{arg1}}} {{arg2}} .function {arg{1}} {arg2}} .function {arg{1} arg} { { arg2 } } body content ================================================ FILE: quarkdown-core/src/test/resources/parsing/heading.md ================================================ # Title ## Title ### Title #! Decorative title ######! Decorative title ####### Not a title a ## Not a title # Not a title ## Title with closing sequence ### # Title with custom ID {#custom-id} ### Title with custom ID {#id} ### ================================================ FILE: quarkdown-core/src/test/resources/parsing/hr.md ================================================ --- *** ___ ------ ********** ________ -- ** _ +++ === --- A X --- Y ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/codespan.md ================================================ `foo` `` foo ` bar `` ` `` ` ` `` ` ` a` ` b ` `` foo bar baz `` `#FF00FF` ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/emphasis.md ================================================ _foo_ *foo* _foo**bar**baz_ _foo_bar_baz_ _foo*bar*baz_ _foo bar)_ _(foo bar)_ ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/entity.md ================================================ &#35; &#1234; &#992; &#0; &#X22; &#XD06; &#xcab; &nbsp; &amp; &copy; &AElig; ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/escape.md ================================================ \#\!\.\,\[\] ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/image.md ================================================ ![foo](/img) ![foo](/img 'Title') ![foo]( /img "Title" ) !(150x100)[foo](/img (Title)) !(150x_)[foo](/img) !(_x100)[foo](/img) !(_x_)[foo](/img) !(140)[foo](/img) !(2cm*4.2in)[foo](/img) !(20mm*3cm)[foo](/img) !(2px*3)[foo](/img) !(50%*5%)[foo](/img) !(50%x5%)[foo](/img) !(50% 5%)[foo](/img) !(70%)[foo](/img) ![foo](/img) {#custom-id} !(150x100)[foo](/img "Title") {#custom-id} ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/link.md ================================================ [foo](https://google.com) [foo](https://google.com 'Title') [foo]( https://google.com "Title" ) <https://google.com> https://google.com ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/mathspan.md ================================================ $ Math expression $ text $ Math expression $ text $ Math expression $ text text $ Math expression $ Text,$ Math expression $ text $ Math expression $, text Text,$ Math expression $ Text$ Not math expression $ text $ Math $expression $ text $ Math $ abc $ expression $ ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/reffootnote-all-in-one.md ================================================ [^abc: this is a definition!] [^: this is an *anonymous* definition!] ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/reffootnote.md ================================================ [^label] [^1] [^2] ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/refimage.md ================================================ ![label][ref] ![ref] ![ref][] !(150x100)[ref] !(150x_)[ref][] ![label][ref] {#custom-id} !(150x100)[ref] {#custom-id} ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/reflink.md ================================================ [label][ref] [ref] [ref][] ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/strikethrough.md ================================================ ~~foo~~ ~~Hi~~ Hello, ~there~ world! ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/strong.md ================================================ **foo** **foo*bar*baz** __foo_bar_baz__ ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/strongemphasis.md ================================================ ***foo*** ___foo*bar*baz___ ================================================ FILE: quarkdown-core/src/test/resources/parsing/inline/subdocumentlink.md ================================================ [foo](path/to/file.qd) [foo](path/to/file.qd 'Title') [foo](path/to/file.qd#anchor) [foo](path/to/file.qd#anchor#anchor) [foo](path/to/file.md) ================================================ FILE: quarkdown-core/src/test/resources/parsing/linkdefinition.md ================================================ [label]: https://google.com [label]: url [label]: /url [label]: https://google.com "Title" [label]: https://google.com 'Title' [label]: https://google.com (Title) [label]: https://google.com " Title " [label]: https://google.com "Multiline title" [label]: https://google.com ' Line 1 Line 2 Line 3 ' [label]: /url 'Title' ================================================ FILE: quarkdown-core/src/test/resources/parsing/math_multiline.md ================================================ $$$ Math expression $$$ A paragraph $$$ Math expression $$$ $$$ Math expression $$$ ``` $$$ Not math $$$ ``` $$$ Line 1 Line 2 $$$ $$$ {#custom-id} Line 1 Line 2 $$$ ================================================ FILE: quarkdown-core/src/test/resources/parsing/math_oneline.md ================================================ $ Math expression $ $Not math $ $ Not math$ $Not math $ A paragraph $ Not math: this is inline math $ $ Math expression $ $ Not math $ $ Not math $ $ Not math $ some text $ Not math $ abc $ Not math $ $ Math $expression $ $ Math expression$ $ $ Math expression $ {#custom-id} ================================================ FILE: quarkdown-core/src/test/resources/parsing/orderedlist.md ================================================ 1. A 2. B 3. C 1. A 2. B 3) C 1. A Some paragraph 2. B 1. Nested 1 1. Nested A Some paragraph 2. Nested B 3. C > Some quote 4. D Some paragraph 1. E ``` Some code ``` 5. 1. E End of list 1. Another list with lazy line 2. B Some paragraph with lazy line 3. # Heading 4. C 5. # Heading Some paragraph # End of list 9. A 10. B --- 1. A > End of list 1. > Quote 2. A ``` End of list ``` 003. A 004. ``` Some multiline code ``` ## End of list 1. [x] Checked 2. [X] Checked 3. [ ] Unchecked ## End of list 1. A 2. B (not enough indentation to nest) 1. [x] C (nested in C) ================================================ FILE: quarkdown-core/src/test/resources/parsing/pagebreak.md ================================================ <<< <<<not break <<<<<< < < < < ================================================ FILE: quarkdown-core/src/test/resources/parsing/paragraph.md ================================================ Paragraph 1 Paragraph 2 Paragraph 3 Paragraph 4 with lazy line ================================================ FILE: quarkdown-core/src/test/resources/parsing/setextheading.md ================================================ Title 1 === Title 1 ====== Title 1 = Title 2 --- Title 2 ---------- Title 2 - Title with ID {#my-id} ==== ================================================ FILE: quarkdown-core/src/test/resources/parsing/table.md ================================================ | foo | bar | |-----|-----| | abc | def | | ghi | jkl | | abc | defghi | :-: | -----------: bar | baz | f\|oo | | ------ | | b `\|` az | | b **\|** im | | abc | def | | --- |:----| | bar | | bar | baz | boo | | | A | B | |:------|----|---:| | **C** | AC | BC | | **D** | AD | BD | |||| |-|-|-| | | || | A B C | D E F | |-------|-------| | G H I | J K L | | M N O | P Q R | "Table caption" | A B C | D E F | |-------|-------| | G H I | J K L | | M N O | P Q R | "Table caption" | S T U | V W X | | A B C | D E F | |-------|-------| | G H I | J K L | | M N O | P Q R | {#custom-id} | A B C | D E F | |-------|-------| | G H I | J K L | | M N O | P Q R | "Table caption" {#custom-id} ================================================ FILE: quarkdown-core/src/test/resources/parsing/unorderedlist.md ================================================ - A - B - C - A - B * C - A Some paragraph - B - Nested 1 - Nested A Some paragraph - Nested B - C > Some quote - D Some paragraph - E ``` Some code ``` - - E End of list - Another list with lazy line - B Some paragraph with lazy line - # Heading - C - # Heading Some paragraph # End of list - A - B --- - A > End of list - > Quote - A ``` End of list ``` - A - ``` Some multiline code ``` ## End of list - [x] Checked - [X] Checked - [ ] Unchecked ## End of list - A - B (not enough indentation to nest) - [x] C (nested in B) ================================================ FILE: quarkdown-core/src/test/resources/subdoc/subdoc-1.qd ================================================ ================================================ FILE: quarkdown-core/src/test/resources/subdoc/subdoc-2.qd ================================================ ================================================ FILE: quarkdown-core/src/test/resources/template/template.jte ================================================ @param String name = null @param boolean ask = false Hello@if(name != null), ${name}@endif! @if(ask) How are you? @endif I'm good. ================================================ FILE: quarkdown-core/src/testFixtures/kotlin/com/quarkdown/core/TestUtils.kt ================================================ package com.quarkdown.core import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.context.MutableContext import com.quarkdown.core.lexer.Lexer import com.quarkdown.core.lexer.tokens.NewlineToken import com.quarkdown.core.lexer.tokens.PlainTextToken import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.Pipelines import com.quarkdown.core.visitor.token.TokenVisitor import org.assertj.core.api.Assertions.assertThat import kotlin.test.assertIs /** * Asserts that the contents of two nodes are equal. * @param expected expected node * @param actual actual node */ fun assertNodeEquals( expected: Node, actual: Node, ) = assertThat(actual) .usingRecursiveComparison() .isEqualTo(expected)!! /** * Asserts that the contents of two inline content nodes are equal. * @param expected expected node * @param actual actual node */ fun assertNodeEquals( expected: InlineContent, actual: InlineContent, ) = assertNodeEquals(AstRoot(expected), AstRoot(actual)) /** * Reads the text content of a test resource. * @param path path to the test resource * @return text of the test resource * @throws IllegalAccessError if the resource was not found */ fun readSource(path: String) = object {}::class.java .getResourceAsStream(path) ?.bufferedReader() ?.readText() ?: throw IllegalAccessError("No resource $path") /** * Tokenizes and parses some input. * @param assertType if `true`, asserts each output node is of type [T] * @param lexer lexer to use to tokenize * @param parser parser to use on each token * @param T type of the nodes to output * @return iterator of the parsed nodes */ inline fun <reified T : Node> nodesIterator( lexer: Lexer, parser: TokenVisitor<Node>, assertType: Boolean = true, ): Iterator<T> = lexer .tokenize() .filterNot { it is NewlineToken } .filterNot { it is PlainTextToken && it.data.text.isBlank() } .map { it to it.accept(parser) } .onEach { (token, node) -> if (assertType) { assertIs<T>( node, message = "From token:\n${token.data.text}\n\n", ) } }.map { it.second } .filterIsInstance<T>() .iterator() /** * Attaches a mock pipeline to a context for tests only, which does not support rendering. * @param options options of the pipeline */ fun MutableContext.attachMockPipeline(options: PipelineOptions = PipelineOptions()): Pipeline { val pipeline = Pipeline( this, options, libraries = emptySet(), renderer = { _, _ -> throw UnsupportedOperationException() }, ) Pipelines.attach(this, pipeline) return pipeline } ================================================ FILE: quarkdown-html/.gitignore ================================================ # Esbuild src/main/resources/render/script/quarkdown.js src/main/resources/render/script/quarkdown.js.map # Sass src/main/resources/render/theme/**/*.css src/main/resources/render/theme/**/*.css.map # e2e **/playwright-report/ **/test-results/ ================================================ FILE: quarkdown-html/README.md ================================================ # html This module provides an extension to render Quarkdown ASTs to: - HTML, CSS and JavaScript; - PDF (via [Puppeteer](https://pptr.dev)). ## Options - `--render html` for HTML rendering - `--render html --pdf` or `--render html-pdf` for PDF rendering. `-r` is short for `--render`. Note that Quarkdown's CLI uses `--render html` by default, hence PDF rendering can be achieved via just `--pdf`. ================================================ FILE: quarkdown-html/build.gradle.kts ================================================ import com.github.gradle.node.npm.task.NpmTask import com.github.gradle.node.npm.task.NpxTask plugins { kotlin("jvm") kotlin("plugin.serialization") version "2.3.10" id("com.github.node-gradle.node") version "7.1.0" id("io.miret.etienne.sass") version "1.6.0" } dependencies { testImplementation(kotlin("test")) testImplementation(testFixtures(project(":quarkdown-core"))) testImplementation("org.apache.pdfbox:pdfbox:3.0.6") implementation(project(":quarkdown-core")) implementation(project(":quarkdown-interaction")) implementation(project(":quarkdown-server")) implementation(project(":quarkdown-plaintext")) // For search index generation implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.12.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") } tasks.compileSass { sourceDir = projectDir.resolve("src/main/scss") outputDir = projectDir.resolve("src/main/resources/render/theme") } val bundleTypeScript = tasks.register<NpxTask>("bundleTypeScript") { group = "build" description = "Bundles TypeScript files using esbuild" // Make sure npm install runs first dependsOn(tasks.npmInstall) command.set("esbuild") args.set( listOf( "src/main/typescript/index.ts", "--bundle", "--platform=browser", "--format=iife", "--outfile=src/main/resources/render/script/quarkdown.js", "--external:reveal.js", "--external:pagedjs", "--external:katex", "--external:highlight.js", "--external:mermaid", "--sourcemap", ), ) } tasks.processResources { dependsOn(tasks.compileSass) dependsOn(bundleTypeScript) } val npmUnitTest = tasks.register<NpmTask>("npmTest") { group = "verification" description = "Runs npm tests" dependsOn(tasks.npmInstall) args.set(listOf("run", "test:run")) } tasks.test { dependsOn(npmUnitTest) } val installPlaywrightBrowsers = tasks.register<NpxTask>("installPlaywrightBrowsers") { group = "verification" description = "Installs Playwright browsers" dependsOn(tasks.npmInstall) command.set("playwright") args.set(listOf("install", "--with-deps", "chromium")) } val npmE2eTest = tasks.register<NpxTask>("e2eTest") { group = "verification" description = "Runs end-to-end tests" dependsOn(installPlaywrightBrowsers) command.set("playwright") val shard = project.findProperty("shard")?.toString() val totalShards = project.findProperty("totalShards")?.toString() val argsList = mutableListOf("test") if (shard != null && totalShards != null) { argsList.add("--shard=$shard/$totalShards") } args.set(argsList) } ================================================ FILE: quarkdown-html/package.json ================================================ { "name": "quarkdown-html", "version": "1.0.0", "description": "Browser runtime for quarkdown-html", "license": "GPL-3.0", "author": "Giorgio Garofalo & Quarkdown contributors", "type": "commonjs", "main": "src/main/typescript/index.ts", "scripts": { "test": "vitest", "test:run": "vitest run", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, "devDependencies": { "esbuild": "^0.25.10", "reveal.js": "^5.2.1", "@types/reveal.js": "^5.2.1", "pagedjs": "^0.4.3", "katex": "^0.16.23", "@types/katex": "^0.16.3", "highlight.js": "^11.11.1", "mermaid": "^11.12.0", "minisearch": "^7.2.0", "vitest": "^3.2.4", "happy-dom": "^20.0.2", "@types/node": "^24.7.2", "@playwright/test": "^1.57.0", "romans": "^3.1.0" } } ================================================ FILE: quarkdown-html/playwright.config.ts ================================================ import {defineConfig, devices} from "@playwright/test"; export default defineConfig({ testDir: "./src/test/e2e", globalSetup: "./src/test/e2e/__util/global-setup.ts", globalTeardown: "./src/test/e2e/__util/global-teardown.ts", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: "list", use: { trace: "on-first-retry", }, projects: [ { name: "chromium", use: {...devices["Desktop Chrome"]}, }, ], }); ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/HtmlIdentifierProvider.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.attributes.id.IdentifierProvider import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.rendering.NodeRenderer import com.quarkdown.core.util.node.toPlainText /** * Provides identifiers for elements suitable for HTML rendering. * @param renderer renderer that this provider should use to convert nodes to plain text via [toPlainText] * @see IdentifierProvider */ class HtmlIdentifierProvider private constructor( private val renderer: NodeRenderer?, ) : IdentifierProvider<String> { /** * Converts [this] string to a URI-like string removing special characters and replacing whitespaces with dashes. * Example: "Hello, World!" -> "hello-world" * @return URI-like string */ private fun String.toURIString() = this .lowercase() .replace("\\s+".toRegex(), "-") .replace("[^\\p{L}\\p{N}-]".toRegex(), "") /** * Ensures that the string is a valid identifier for HTML elements: * - It is not empty. * - It does not start with a digit (#86). * @return a valid identifier string, possibly different from the original */ private fun String.asValidId(): String = when { isEmpty() -> "_" first().isDigit() -> "_$this" else -> this } override fun visit(heading: Heading) = (heading.customId ?: heading.text.toPlainText(renderer).toURIString()) .asValidId() override fun visit(footnote: FootnoteDefinition): String = "__footnote-${footnote.label.toURIString()}" .asValidId() companion object { /** * Creates an instance of [HtmlIdentifierProvider]. * @param renderer renderer that this provider should use to convert nodes to plain text via [toPlainText] */ fun of(renderer: NodeRenderer?) = HtmlIdentifierProvider(renderer) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/HtmlTagBuilder.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.Node import com.quarkdown.core.rendering.tag.TagBuilder import com.quarkdown.core.rendering.tag.tagBuilder import com.quarkdown.core.util.indent import com.quarkdown.rendering.html.css.CssBuilder import com.quarkdown.rendering.html.css.css import com.quarkdown.rendering.html.node.BaseHtmlNodeRenderer /** * String used to indent nested code. */ private const val INDENT = " " /** * A builder of an HTML tag. * * Example: * ``` * val builder = tagBuilder("html") { * tag("head") { * tag("meta") * .attribute("charset", "UTF-8") * .void(true) * } * tag("body") { * +node.children * } * ``` * * @see TagBuilder * @see tagBuilder */ class HtmlTagBuilder( private val name: String, private val renderer: BaseHtmlNodeRenderer, private val pretty: Boolean, ) : TagBuilder(name, renderer, pretty) { /** * Attributes of the tag. */ private val attributes = mutableMapOf<String, Any>() /** * Whether the tag does not expect a closing tag. */ private var isVoid = false /** * Adds an attribute to this tag. * @param key attribute key * @param value attribute value * @return this for concatenation */ fun attribute( key: String, value: Any, ) = apply { this.attributes[key] = value } /** * Adds an attribute to this tag only if [value] is not `null`. * @param key attribute key * @param value attribute value, applied only if not `null` * @return this for concatenation */ fun optionalAttribute( key: String, value: Any?, ) = apply { if (value != null) attribute(key, value) } /** * Sets whether this tag is void (without a closing tag). * @param isVoid whether the tag does not expect a closing tag * @return this for concatenation */ fun void(isVoid: Boolean) = apply { this.isVoid = isVoid } /** * Applies a CSS style via the `style` attribute to this tag. * The attribute is _not_ added if the generated CSS string is empty. * @param init CSS builder initialization * @return this for concatenation * @see com.quarkdown.rendering.html.css.css */ fun style(init: CssBuilder.() -> Unit) = optionalAttribute("style", css(init).takeUnless { it.isEmpty() }) /** * Applies a single class name via the `class` attribute to this tag. * @param className class name. The attribute is not applied if it's `null` * @return this for concatenation * @see optionalAttribute */ fun className(className: String?) = optionalAttribute("class", className) /** * Applies a sequence of class names via the `class` attribute to this tag. * @param classNames class names. `null` elements are ignored. The attribute is not applied if all elements are `null` * @return this for concatenation */ fun classNames(vararg classNames: String?) = optionalAttribute( "class", classNames .asSequence() .filterNotNull() .joinToString(separator = " ") .takeIf { it.isNotEmpty() }, ) /** * Adds a `data-hidden` attribute to this tag as a flag that this is a hidden element. * A page that has either zero elements or only hidden elements is considered blank. * This attribute is usually read by external stylesheets and scripts. * @return this for concatenation */ fun hidden() = attribute("data-hidden", "") /** * Adds a `data-accept-empty` attribute to this tag as a flag that this element is to keep even if it has no content. * By default, empty elements, such as paragraphs without text, are hidden to avoid unwanted empty spaces. * @return this for concatenation */ fun acceptEmpty() = attribute("data-accept-empty", "") /** * @return this builder and its nested content into stringified HTML code. */ override fun build(): String = buildString { fun CharSequence.indent() = if (pretty) this.indent(INDENT) else this // Opening tag. append("<") append(name) attributes.entries.forEach { (key, value) -> append(" $key=\"$value\"") } // If this is a void tag, neither content nor closing tag is expected. if (isVoid) { append(" />") return@buildString } append(">") if (pretty) { append("\n") } // Indented content from inner tags. builders.forEach { builder -> append(builder.build().indent()) } // Indented text content. append(content.indent()) // Closing tag. append("</") append(name) append(">") } override fun append(content: CharSequence) { when { pretty -> super.content.append(content.trimEnd()).append("\n") else -> super.content.append(content) } } /** * Appends a sub-tag. * @param name name of the tag * @param init action to run at initialization * @return this for concatenation */ fun tag( name: String, init: HtmlTagBuilder.() -> Unit = {}, ) = renderer.tagBuilder(name, pretty, init).also { builders += it } /** * Appends a sub-tag. * @param name name of the tag * @param content nodes to render as HTML within the tag * @return this for concatenation */ fun tag( name: String, content: List<Node>, ) = tag(name) { +content } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/css/CssBuilder.kt ================================================ package com.quarkdown.rendering.html.css import com.quarkdown.core.rendering.representable.RenderRepresentable /** * A builder of CSS declarations (property-value pairs), with null-safe insertion. * * Supports two output formats: * - [build] produces a single-line inline style (e.g. for the `style` HTML attribute). * - [buildBlock] produces an indented, multi-line block (e.g. for a stylesheet rule body). */ class CssBuilder { /** * Key-value CSS entries. */ private val entries = mutableMapOf<String, String>() /** * Pushes a key-value CSS entry as long as [value] is not `null`. * @param key CSS entry key * @param value CSS entry value * @return this for concatenation */ private fun entry( key: String, value: String?, ) = apply { value?.let { entries[key] = it } } /** * Pushes a key-value CSS entry. * @param key CSS entry key * @param value CSS entry value * @return this for concatenation */ private fun entry( key: String, value: RenderRepresentable?, ) = entry(key, value?.asCSS) /** * Shorthand syntactic sugar for [entry]. * @see entry */ infix fun String.value(value: String?) = entry(this, value) /** * Shorthand syntactic sugar for [entry]. * @see entry */ infix fun String.value(value: RenderRepresentable?) = entry(this, value) /** * Like [value], but appends `!important` to the declaration. * @see value */ infix fun String.importantValue(value: String?) = entry(this, value?.let { "$it !important" }) /** * Like [value], but appends `!important` to the declaration. * @see value */ infix fun String.importantValue(value: RenderRepresentable?) = importantValue(value?.asCSS) /** * @return a single-line string representation of the CSS entries, suitable for inline styles */ fun build() = entries.entries.joinToString(separator = " ") { "${it.key}: ${it.value};" } /** * @return an indented, multi-line string representation of the CSS entries, * suitable for a rule body in a stylesheet block */ fun buildBlock(indent: String = " ") = entries.entries.joinToString(separator = "\n") { "$indent${it.key}: ${it.value};" } } /** * Example usage: * ``` * val css = css { * "color" value Color(255, 0, 0) * "font-size" value Size(16, SizeUnit.PX) * } * ``` * @return a string representation of CSS entries contained within the builder. */ fun css(init: CssBuilder.() -> Unit): String = CssBuilder().apply(init).build() ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/css/CssPageSelectors.kt ================================================ package com.quarkdown.rendering.html.css import com.quarkdown.core.document.layout.page.PageFormatSelector /** * Converter of a [PageFormatSelector] into `@page` CSS selectors. */ object CssPageSelectors { /** * Expands a [PageFormatSelector] into individual `@page` CSS selectors. * Since `@page` pseudo-class selectors cannot be comma-separated, * each page in a range produces its own selector string. * * - `null`: `["@page"]` * - Side-only: `["@page:left"]` * - Range-only: `["@page:nth(1)", "@page:nth(2)", ...]` * - Side + range: `["@page:nth(1):left", "@page:nth(2):left", ...]` * * @throws IllegalArgumentException if the range has no end bound */ fun toCss(selector: PageFormatSelector?): List<String> { val sideSuffix = selector?.side?.let { ":${it.asCSS}" }.orEmpty() val range = selector?.range ?: return listOf("@page$sideSuffix") val end = requireNotNull(range.end) { "Open-ended page ranges are not supported in CSS selectors." } val start = range.start ?: 1 return (start..end).map { n -> "@page:nth($n)$sideSuffix" } } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/css/CssRepresentableVisitor.kt ================================================ package com.quarkdown.rendering.html.css import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.document.layout.caption.CaptionPosition import com.quarkdown.core.document.layout.page.PageMarginPosition import com.quarkdown.core.document.layout.page.PageSide import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.document.slides.Transition import com.quarkdown.core.misc.color.Color import com.quarkdown.core.rendering.representable.RenderRepresentable import com.quarkdown.core.rendering.representable.RenderRepresentableVisitor import com.quarkdown.core.util.kebabCaseName /** * Producer of CSS representations of [RenderRepresentable]s. */ class CssRepresentableVisitor : RenderRepresentableVisitor<String> { override fun visit(color: Color) = with(color) { "rgba($red, $green, $blue, $alpha)" } override fun visit(size: Size) = "${size.value}${size.unit.symbol}" // e.g. 10px, 5cm, 2in override fun visit(sizes: Sizes) = with(sizes) { "${visit(top)} ${visit(right)} ${visit(bottom)} ${visit(left)}" } override fun visit(alignment: Table.Alignment) = alignment.kebabCaseName override fun visit(position: CaptionPosition) = position.kebabCaseName override fun visit(borderStyle: Container.BorderStyle) = when (borderStyle) { Container.BorderStyle.NORMAL -> "solid" else -> borderStyle.kebabCaseName } override fun visit(alignment: Container.Alignment) = alignment.kebabCaseName override fun visit(alignment: Container.TextAlignment) = alignment.kebabCaseName override fun visit(alignment: Container.FloatAlignment) = "inline-${alignment.kebabCaseName}" override fun visit(stackLayout: Stacked.Layout) = when (stackLayout) { is Stacked.Column -> "column" is Stacked.Row -> "row" is Stacked.Grid -> "grid" } override fun visit(alignment: Stacked.MainAxisAlignment) = when (alignment) { Stacked.MainAxisAlignment.START -> "flex-start" Stacked.MainAxisAlignment.END -> "flex-end" else -> alignment.kebabCaseName } override fun visit(alignment: Stacked.CrossAxisAlignment) = when (alignment) { Stacked.CrossAxisAlignment.START -> "flex-start" Stacked.CrossAxisAlignment.END -> "flex-end" else -> alignment.kebabCaseName } override fun visit(clip: Clipped.Clip) = clip.kebabCaseName override fun visit(quoteType: BlockQuote.Type) = quoteType.kebabCaseName override fun visit(boxType: Box.Type): String = boxType.kebabCaseName override fun visit(navigationRole: NavigationContainer.Role) = when (navigationRole) { NavigationContainer.Role.PAGE_LIST -> "navigation" else -> navigationRole.kebabCaseName } override fun visit(position: PageMarginPosition) = position.kebabCaseName override fun visit(transition: Transition.Style) = transition.kebabCaseName override fun visit(speed: Transition.Speed) = speed.kebabCaseName override fun visit(behavior: SlidesFragment.Behavior) = when (behavior) { SlidesFragment.Behavior.SHOW -> "fade-in" SlidesFragment.Behavior.HIDE -> "fade-out" SlidesFragment.Behavior.SEMI_HIDE -> "semi-fade-out" SlidesFragment.Behavior.SHOW_HIDE -> "fade-in-then-out" } override fun visit(size: TextTransformData.Size) = "size-${size.kebabCaseName}" override fun visit(weight: TextTransformData.Weight) = weight.kebabCaseName override fun visit(style: TextTransformData.Style) = style.kebabCaseName override fun visit(decoration: TextTransformData.Decoration) = when (decoration) { TextTransformData.Decoration.STRIKETHROUGH -> "line-through" TextTransformData.Decoration.UNDEROVERLINE -> "underline overline" TextTransformData.Decoration.ALL -> "underline overline line-through" else -> decoration.kebabCaseName } override fun visit(case: TextTransformData.Case) = case.kebabCaseName override fun visit(variant: TextTransformData.Variant) = variant.kebabCaseName override fun visit(script: TextTransformData.Script) = script.kebabCaseName override fun visit(pageSide: PageSide) = pageSide.kebabCaseName } /** * Converts a [RenderRepresentable] to its CSS representation. * @see CssRepresentableVisitor */ val RenderRepresentable.asCSS: String get() = accept(CssRepresentableVisitor()) ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/css/StylesheetBuilder.kt ================================================ package com.quarkdown.rendering.html.css /** * Lightweight DSL for building a CSS stylesheet string. * Rule bodies reuse [CssBuilder] for null-safe property insertion. */ class StylesheetBuilder { private val parts = mutableListOf<String>() /** Appends a raw CSS snippet (e.g. `@font-face` or `@import` declarations). */ fun raw(content: String) { parts += content } /** Appends a CSS rule block with one or more [selectors]. */ fun rule( vararg selectors: String, block: CssBuilder.() -> Unit, ) { val body = CssBuilder().apply(block).buildBlock() if (body.isNotBlank()) { parts += "${selectors.joinToString(",\n")} {\n$body}" } } fun build(): String = parts.joinToString("\n\n") } /** * Example usage: * ``` * val css = stylesheet { * raw("@import url('fonts.css');") * rule("body") { * "color" value Color(0, 0, 0) * "font-size" value Size(16, SizeUnit.PX) * } * } * ``` * @return a string representation of a CSS stylesheet built within the given [block] */ fun stylesheet(block: StylesheetBuilder.() -> Unit): String = StylesheetBuilder().apply(block).build() ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/extension/HtmlRendererExtension.kt ================================================ package com.quarkdown.rendering.html.extension import com.quarkdown.core.context.Context import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.rendering.RenderingComponents import com.quarkdown.rendering.html.pdf.HtmlPdfExportOptions import com.quarkdown.rendering.html.pdf.PdfHtmlPostRendererDecorator import com.quarkdown.rendering.html.post.HtmlPostRenderer import com.quarkdown.rendering.html.post.HtmlSubdocumentPostRenderer /** * The HTML rendering plug-in produces a browser-compatible document. * * - The root document comes with a full export which includes themes and scripts, and possibly media resources. * - Other subdocuments are exported to lightweight subdirectories, with possibly media resources. */ fun RendererFactory.html(context: Context) = RenderingComponents( nodeRenderer = accept(HtmlRendererFactoryVisitor(context)), postRenderer = when (context.subdocument) { Subdocument.Root -> HtmlPostRenderer(context) else -> HtmlSubdocumentPostRenderer(context) }, ) /** * The HTML-PDF rendering plug-in produces a PDF document from the HTML output of [html]. * The outcome is 1:1 with what would be displayed in a Chrome browser. */ fun RendererFactory.htmlPdf( context: Context, options: HtmlPdfExportOptions, ) = RenderingComponents( nodeRenderer = accept(HtmlRendererFactoryVisitor(context)), postRenderer = PdfHtmlPostRendererDecorator( HtmlPostRenderer(context), options = options, ), ) ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/extension/HtmlRendererFactoryVisitor.kt ================================================ package com.quarkdown.rendering.html.extension import com.quarkdown.core.context.Context import com.quarkdown.core.flavor.RendererFactoryVisitor import com.quarkdown.core.flavor.base.BaseMarkdownRendererFactory import com.quarkdown.core.flavor.quarkdown.QuarkdownRendererFactory import com.quarkdown.core.rendering.NodeRenderer import com.quarkdown.rendering.html.node.BaseHtmlNodeRenderer import com.quarkdown.rendering.html.node.QuarkdownHtmlNodeRenderer /** * Supplier of an HTML node renderer from the active renderer factory. */ class HtmlRendererFactoryVisitor( private val context: Context, ) : RendererFactoryVisitor<NodeRenderer> { override fun visit(factory: BaseMarkdownRendererFactory) = BaseHtmlNodeRenderer(context) override fun visit(factory: QuarkdownRendererFactory) = QuarkdownHtmlNodeRenderer(context) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/BaseHtmlNodeRenderer.kt ================================================ package com.quarkdown.rendering.html.node import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.attributes.id.getId import com.quarkdown.core.ast.attributes.link.getResolvedUrl import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.getFormattedIndex import com.quarkdown.core.ast.base.block.getIndex import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.ListItemVariantVisitor import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.TaskListItemVariant import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.base.inline.getSubdocument import com.quarkdown.core.ast.media.getStoredMedia import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.Landscape import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.SlidesSpeakerNote import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.ast.quarkdown.block.list.FocusListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.LocationTargetListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.TableOfContentsItemVariant import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.PageCounter import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.Whitespace import com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer import com.quarkdown.core.ast.quarkdown.invisible.PageNumberFormatter import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.context.Context import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.rendering.UnsupportedRenderException import com.quarkdown.core.rendering.tag.TagNodeRenderer import com.quarkdown.core.rendering.tag.buildMultiTag import com.quarkdown.core.rendering.tag.buildTag import com.quarkdown.core.rendering.tag.tagBuilder import com.quarkdown.core.util.Escape import com.quarkdown.core.util.node.toPlainText import com.quarkdown.rendering.html.HtmlIdentifierProvider import com.quarkdown.rendering.html.HtmlTagBuilder import com.quarkdown.rendering.html.css.asCSS /** * A renderer for vanilla Markdown ([com.quarkdown.core.flavor.base.BaseMarkdownFlavor]) nodes that exports their content into valid HTML code. * @param context additional information produced by the earlier stages of the pipeline */ open class BaseHtmlNodeRenderer( context: Context, ) : TagNodeRenderer<HtmlTagBuilder>(context), // Along with nodes, this component is also responsible for rendering list item variants. // For instance, a checked/unchecked task of attached to a list item. // These flavors directly affect the behavior of the HTML list item builder. ListItemVariantVisitor<HtmlTagBuilder.() -> Unit> { override fun createBuilder( name: String, pretty: Boolean, ) = HtmlTagBuilder(name, renderer = this, pretty) override fun escapeCriticalContent(unescaped: String) = Escape.Html.escape(unescaped) // Root override fun visit(node: AstRoot) = buildMultiTag { +node.children } // Block override fun visit(node: Newline) = "" override fun visit(node: Code) = buildTag("pre") { tag("code") { +escapeCriticalContent(node.content) classNames( // Sets the code language. node.language?.let { "language-$it" }, // Disables syntax highlighting. "no-highlight".takeUnless { node.highlight }, // Disables line numbers. "nohljsln".takeUnless { node.showLineNumbers }, // Focuses certain lines. "focus-lines".takeIf { node.focusedLines != null }, ) // Focus range. optionalAttribute("data-focus-start", node.focusedLines?.start) optionalAttribute("data-focus-end", node.focusedLines?.end) } } override fun visit(node: HorizontalRule) = tagBuilder("hr") .void(true) .build() override fun visit(node: Heading) = buildTag("h${node.depth}", node.text) override fun visit(node: LinkDefinition) = "" // Not rendered override fun visit(node: FootnoteDefinition): CharSequence { val index = node.getIndex(context) ?: return "" // The footnote is rendered only if it is linked to a reference val formattedIndex = node.getFormattedIndex(context) ?: return "" return buildTag("span") { className("footnote-definition") optionalAttribute("id", HtmlIdentifierProvider.of(this@BaseHtmlNodeRenderer).getId(node)) optionalAttribute("data-footnote-index", index) tag("sup") { className("footnote-label") +formattedIndex } tag("span") { +node.text } } } override fun visit(node: OrderedList) = tagBuilder("ol", node.children) .optionalAttribute("start", node.startIndex.takeUnless { it == 1 }) .build() override fun visit(node: UnorderedList) = buildTag("ul", node.children) // Appends the base content of a list item, following the loose/tight rendering rules (CommonMark 5.3). override fun visit(node: ListItem) = buildTag("li") { // Flavors are executed on this HTML builder. node.variants.forEach { it.accept(this@BaseHtmlNodeRenderer).invoke(this) } // Loose lists (or items not linked to a list for some reason) are rendered as-is. if (node.owner?.isLoose != false) { // This base builder is empty by default. // If any of the variants added some content (e.g. a task checkbox), // the actual content is wrapped in a container for more convenient styling. when { this.isEmpty -> +node.children else -> +buildTag("div", node.children) } return@buildTag } // Tight lists don't wrap paragraphs in <p> tags (CommonMark 5.3). node.children.forEach { when (it) { is Paragraph -> +it.text else -> +it } } } // GFM 5.3 extension. override fun visit(variant: TaskListItemVariant): HtmlTagBuilder.() -> Unit = { className("task-list-item") +visit(CheckBox(variant.isChecked)) } override fun visit(node: Html) = node.content /** * Table tag builder, enhanceable by subclasses. */ protected fun tableBuilder(node: Table): HtmlTagBuilder = tagBuilder("table") { // Tables are stored by columns and here transposed to a row-based structure. val header = tag("thead") val headerRow = header.tag("tr") val body = tag("tbody") val bodyRows = mutableListOf<HtmlTagBuilder>() node.columns.forEach { column -> // Value to assign to the 'align' attribute for each cell of this column. val alignment = column.alignment.takeUnless { it == Table.Alignment.NONE }?.asCSS // Header cell. headerRow .tag("th", column.header.text) .optionalAttribute("align", alignment) // Body cells. column.cells.forEachIndexed { index, cell -> // Adding a new row if needed. if (index >= bodyRows.size) { bodyRows += body.tag("tr") } // Adding a cell. bodyRows[index] .tag("td", cell.text) .optionalAttribute("align", alignment) } } } override fun visit(node: Table) = tableBuilder(node).build() override fun visit(node: Paragraph) = buildTag("p", node.text) override fun visit(node: BlockQuote) = buildTag("blockquote", node.children) override fun visit(node: BlankNode) = "" // Fallback block, should not happen // Inline override fun visit(node: Comment) = "" // Ignored override fun visit(node: LineBreak) = tagBuilder("br") .void(true) .build() private fun buildLinkTag(node: Link): HtmlTagBuilder = tagBuilder("a", node.label) .attribute("href", node.url) .optionalAttribute("title", node.title) override fun visit(node: Link) = buildLinkTag(node).build() // The fallback node is rendered if a corresponding definition can't be found. override fun visit(node: ReferenceLink): CharSequence = (node.getDefinition(context) ?: return node.fallback().accept(this)).accept(this) override fun visit(node: SubdocumentLink): CharSequence { val subdocument: Subdocument = node.getSubdocument(context) ?: return "[???]" val isCurrentSubdocument = subdocument == this.context.subdocument // Subdocuments are exported flatly. Links from non-root subdocuments must go up one level. val pathToRoot = when (this.context.subdocument) { Subdocument.Root -> "." else -> ".." } val url = buildString { append(pathToRoot) append("/") append(subdocument.getOutputFileName(context)) node.anchor?.let { anchor -> append("#") append(anchor) } } return buildLinkTag(node.link.copy(url = url)) .optionalAttribute("aria-current", "page".takeIf { isCurrentSubdocument }) .build() } override fun visit(node: ReferenceFootnote): CharSequence { val definition: FootnoteDefinition = node.getDefinition(context) ?: return node.fallback().accept(this) return buildTag("sup") { classNames("footnote-reference", "footnote-label") val definitionId = HtmlIdentifierProvider.of(this@BaseHtmlNodeRenderer).getId(definition) attribute("data-definition", definitionId) tag("a") { optionalAttribute("href", "#$definitionId") +(definition.getFormattedIndex(context) ?: "?") } } } override fun visit(node: Image) = tagBuilder("img") .attribute("src", node.link.getStoredMedia(context)?.path ?: node.link.getResolvedUrl(context)) .attribute("alt", node.link.label.toPlainText(renderer = this)) // Emphasis is discarded (CommonMark 6.4) .optionalAttribute("title", node.link.title) .style { "width" value node.width "height" value node.height }.void(true) .build() override fun visit(node: ReferenceImage): CharSequence { val link = node.link.getDefinition(context) ?: return node.link.fallback().accept(this) return Image(link, node.width, node.height, node.referenceId).accept(this) } override fun visit(node: CheckBox) = tagBuilder("input") {} .attribute("disabled", "") .attribute("type", "checkbox") .optionalAttribute("checked", "".takeIf { node.isChecked }) .void(true) .build() override fun visit(node: Text) = node.text override fun visit(node: TextSymbol) = Escape.Html.escape(node.text) // e.g. © -> &copy; override fun visit(node: CodeSpan) = buildTag("code", escapeCriticalContent(node.text)) override fun visit(node: Emphasis) = buildTag("em", node.children) override fun visit(node: Strong) = buildTag("strong", node.children) override fun visit(node: StrongEmphasis) = buildTag("em") { tag("strong") { +node.children } } override fun visit(node: Strikethrough) = buildTag("del", node.children) // Quarkdown - implemented by QuarkdownHtmlNodeRenderer override fun visit(node: FunctionCallNode): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Figure<*>): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: PageBreak): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Math): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Container): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Stacked): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Numbered): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Landscape): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Clipped): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Box): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Collapse): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: Whitespace): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: NavigationContainer): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: TableOfContentsView): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: BibliographyView): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: MermaidDiagram): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: FileTree): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: SubdocumentGraph): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: PageMarginContentInitializer): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: PageNumberFormatter): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: PageNumberReset): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: PageCounter): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: LastHeading): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: SlidesConfigurationInitializer): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: MathSpan): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: TextTransform): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: IconImage): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: InlineCollapse): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: CrossReference): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: BibliographyCitation): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: SlidesFragment): CharSequence = throw UnsupportedRenderException(node) override fun visit(node: SlidesSpeakerNote): CharSequence = throw UnsupportedRenderException(node) override fun visit(variant: FocusListItemVariant): HtmlTagBuilder.() -> Unit = throw UnsupportedRenderException(variant::class) override fun visit(variant: LocationTargetListItemVariant): HtmlTagBuilder.() -> Unit = throw UnsupportedRenderException(variant::class) override fun visit(variant: TableOfContentsItemVariant): HtmlTagBuilder.() -> Unit = throw UnsupportedRenderException(variant::class) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/QuarkdownHtmlNodeRenderer.kt ================================================ package com.quarkdown.rendering.html.node import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.id.Identifiable import com.quarkdown.core.ast.attributes.id.getId import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.getLocationLabel import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.isMarker import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.quarkdown.CaptionableNode import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import com.quarkdown.core.ast.quarkdown.block.Landscape import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.SlidesSpeakerNote import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.ast.quarkdown.block.list.FocusListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.LocationTargetListItemVariant import com.quarkdown.core.ast.quarkdown.block.list.TableOfContentsItemVariant import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.block.toc.convertTableOfContentsToListNode import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.PageCounter import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.ast.quarkdown.inline.Whitespace import com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer import com.quarkdown.core.ast.quarkdown.invisible.PageNumberFormatter import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.context.Context import com.quarkdown.core.context.localization.localizeOrNull import com.quarkdown.core.context.shouldAutoPageBreak import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.document.layout.caption.CaptionPosition import com.quarkdown.core.document.layout.caption.CaptionPositionInfo import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.rendering.tag.buildMultiTag import com.quarkdown.core.rendering.tag.buildTag import com.quarkdown.core.rendering.tag.tagBuilder import com.quarkdown.core.util.Escape import com.quarkdown.core.util.kebabCaseName import com.quarkdown.rendering.html.HtmlIdentifierProvider import com.quarkdown.rendering.html.HtmlTagBuilder import com.quarkdown.rendering.html.css.CssBuilder import com.quarkdown.rendering.html.css.asCSS /** * A renderer for Quarkdown ([com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor]) nodes that exports their content into valid HTML code. * @param context additional information produced by the earlier stages of the pipeline */ class QuarkdownHtmlNodeRenderer( context: Context, ) : BaseHtmlNodeRenderer(context) { /** * A `<div class="styleClass">...</div>` tag. */ private fun div( styleClass: String? = null, init: HtmlTagBuilder.() -> Unit, ) = tagBuilder("div", init = init) .className(styleClass) .build() /** * A `<div class="styleClass">children</div>` tag. */ private fun div( styleClass: String, children: List<Node>, ) = div(styleClass) { +children } /** * Adds a `data-location` attribute to the location-trackable node, if its location is available. * The location is formatted according to [format]. */ private fun HtmlTagBuilder.withLocationLabel(node: LocationTrackableNode) = optionalAttribute( "data-location", node.getLocationLabel(context)?.takeUnless { it.isEmpty() }, ) /** * Adds a `data-localized-kind` attribute to the localizable node. * The kind name is localized according to the current locale. */ private fun HtmlTagBuilder.withLocalizedKind(node: LocalizedKind) = optionalAttribute( "data-localized-kind", context.localizeOrNull(key = node.kindLocalizationKey), ) /** * Retrieves the location-based label of the [node], displays an optional caption preceded by the label, and also applies the label as its ID. * The label is pre-formatted according to the current [NumberingFormat]. * * At the end, thanks to injected CSS variables, the visible outcome is `<localized_kind> <label>: <caption>`. * * @param node node to display the caption, and apply the ID, for * @param captionTagName tag name of the caption element. E.g. "figcaption" for figures, "caption" for tables * @param idPrefix prefix for the ID. For instance, the prefix `figure` lets the ID be `figure-X.Y`, where `X.Y` is the label. * @param positionProvider position of the caption relative to the content * @see CaptionableNode * @see getLocationLabel to retrieve the numbered label */ private fun <T> HtmlTagBuilder.numberedCaption( node: T, captionTagName: String = "figcaption", idPrefix: String = node.kindLocalizationKey, positionProvider: CaptionPositionInfo.() -> CaptionPosition?, ): HtmlTagBuilder where T : CaptionableNode, T : LocationTrackableNode, T : LocalizedKind = this.apply { val position = context.documentInfo.layout.captionPosition .getOrDefault(positionProvider) // The label is set as the ID of the element. val label = node.getLocationLabel(context) label?.let { optionalAttribute("id", "$idPrefix-$it") } if (node.caption == null && label == null) { // No caption and no label: nothing to show. return@apply } +buildTag(captionTagName) { className("caption-${position.asCSS}") withLocationLabel(node) withLocalizedKind(node) node.caption?.let { +escapeCriticalContent(it) } } } // Quarkdown node rendering // The function was already expanded by previous stages: its output nodes are stored in its children. override fun visit(node: FunctionCallNode): CharSequence = visit(AstRoot(node.children)) // Block override fun visit(node: Figure<*>) = buildTag("figure") { +node.child numberedCaption(node, positionProvider = { figures }) } // An empty div that acts as a page break. override fun visit(node: PageBreak) = tagBuilder("div") .className("page-break") .hidden() .build() override fun visit(node: Math) = buildTag("formula") { +node.expression attribute("data-block", "") optionalAttribute("data-location", node.getLocationLabel(context)) } override fun visit(node: Container) = buildTag("div") { classNames( "container", "fullwidth".takeIf { node.fullWidth }, "float".takeIf { node.float != null }, "full-column-span".takeIf { node.fullColumnSpan }, node.textTransform?.size?.asCSS, node.className, ) +node.children style { "width" value node.width "height" value node.height "color" value node.foregroundColor "background-color" value node.backgroundColor "margin" value node.margin "padding" value node.padding "border-color" value node.borderColor "border-width" value node.borderWidth "border-radius" value node.cornerRadius "border-style" value when { // If the border style is set, it is used. node.borderStyle != null -> node.borderStyle // If border properties are set, a normal (solid) border is used. node.borderColor != null || node.borderWidth != null -> Container.BorderStyle.NORMAL // No border style. else -> null } "justify-items" value node.alignment "text-align" value node.textAlignment "float" value node.float node.textTransform?.let { textTransform(it) } } } override fun visit(node: Stacked) = div("stack stack-${node.layout.asCSS}") { +node.children style { (node.layout as? Stacked.Grid)?.let { // The amount of 'auto' matches the amount of columns/rows. "grid-template-columns" value "auto ".repeat(it.columnCount).trimEnd() } "justify-content" value node.mainAxisAlignment "align-items" value node.crossAxisAlignment "row-gap" value node.rowGap "column-gap" value node.columnGap } } override fun visit(node: Numbered) = buildMultiTag { +node.children } override fun visit(node: Landscape) = div("landscape", node.children) override fun visit(node: Clipped) = div("clip clip-${node.clip.asCSS}") { +Container(children = node.children) } override fun visit(node: Box) = div { classNames("box", node.type.asCSS) if (node.title != null) { tag("header") { tag("h4", node.title!!) style { "color" value node.foregroundColor // Must be repeated to force override. "padding" value node.padding } } } // Box actual content. +div("box-content") { +node.children style { "padding" value node.padding } } // Box style. Padding is applied separately to the header and the content. style { "background-color" value node.backgroundColor "color" value node.foregroundColor } } override fun visit(node: Collapse) = buildTag("details") { if (node.isOpen) { attribute("open", "") } tag("summary") { +node.title } +node.children } override fun visit(node: Whitespace) = // If at least one of the dimensions is set, the square will have a fixed size. // Otherwise, a blank character is rendered. when { node.width == null && node.height == null -> { buildTag("span", "&nbsp;") } else -> { buildTag("div") { style { "width" value node.width "height" value node.height } } } } override fun visit(node: NavigationContainer) = buildTag("nav") { optionalAttribute("role", node.role?.asCSS) optionalAttribute("data-role", node.role?.kebabCaseName) +node.children } override fun visit(node: TableOfContentsView): CharSequence { val tableOfContents = context.attributes.tableOfContents ?: return "" val tree: ListBlock = convertTableOfContentsToListNode( node, this@QuarkdownHtmlNodeRenderer, tableOfContents.items, linkUrlMapper = { item -> "#" + HtmlIdentifierProvider.of(this@QuarkdownHtmlNodeRenderer).getId(item.target) }, ) return NavigationContainer( role = NavigationContainer.Role.TABLE_OF_CONTENTS, listOf(tree), ).accept(this) } override fun visit(node: BibliographyView) = buildTag("div") { classNames("bibliography", "bibliography-${node.style.name}") node.bibliography.entries.values.mapIndexed { index, entry -> tag("span") { className("bibliography-entry-label") +node.style.labelProvider.getListLabel(entry, index) } tag("span") { className("bibliography-entry-content") +node.style.contentOf(entry) } } } override fun visit(node: MermaidDiagram) = buildTag("pre") { classNames("mermaid", "fill-height") +escapeCriticalContent(node.code) } override fun visit(node: FileTree): CharSequence = buildTag("div") { className("file-tree") fun buildEntries(entries: List<FileTreeEntry>): CharSequence = buildTag("ul") { entries.forEach { entry -> tag("li") { when (entry) { is FileTreeEntry.File -> { className("file") attribute("data-name", escapeCriticalContent(entry.name)) +CriticalContent(entry.name) } is FileTreeEntry.Directory -> { className("directory") attribute("data-name", escapeCriticalContent(entry.name)) +CriticalContent(entry.name) +buildEntries(entry.entries) } is FileTreeEntry.Ellipsis -> { className("ellipsis") +"&hellip;" } } if (entry.highlighted) { attribute("data-highlighted", "") } } } } +buildEntries(node.entries) } override fun visit(node: SubdocumentGraph): CharSequence { fun id(subdocument: Subdocument) = subdocument.name.hashCode() val content = "graph LR\n" + context.subdocumentGraph.edges.joinToString("\n") { edge -> val from = edge.first val to = edge.second val (idFrom, idTo) = id(from) to id(to) val (nameFrom, nameTo) = from.name to to.name "$idFrom[\"$nameFrom\"] --> $idTo[\"$nameTo\"]" } return MermaidDiagram(content).accept(this) } // Inline override fun visit(node: MathSpan) = buildTag("formula", node.expression) override fun visit(node: CrossReference): CharSequence { val definition: CrossReferenceableNode = node.getDefinition(context) ?: return Text("[???]").accept(this) // The target node could have an ID. If so, the reference is a link to that node. val anchorId = (definition as? Identifiable)?.accept(HtmlIdentifierProvider.of(this)) val reference = buildTag("span") { className("cross-reference") when (definition) { is LocationTrackableNode if definition.getLocationLabel(context) != null -> { withLocationLabel(definition) } // If no label is available, use the caption if possible. is CaptionableNode if definition.caption != null -> { +definition.caption!! } // Fallback: use the target's text if possible. is TextNode -> { +definition.text } // Fallback: raw reference ID. else -> { +node.referenceId } } if (definition is LocalizedKind) { withLocalizedKind(definition) } } return when (anchorId) { null -> { reference } // No linkable ID. else -> { buildTag("a") { // ID available: link to the target. attribute("href", "#$anchorId") +reference } } } } override fun visit(node: BibliographyCitation): CharSequence { val (entries: List<BibliographyEntry>, view: BibliographyView) = node.getDefinition(context) ?: return Text("[???]").accept(this) val label = view.style.labelProvider.getCitationLabel(entries) return Text(label).accept(this) } override fun visit(node: SlidesFragment) = tagBuilder("div", node.children) .classNames("fragment", node.behavior.asCSS) .build() override fun visit(node: SlidesSpeakerNote) = buildTag("aside") { className("notes") hidden() +node.children } /** * Applies the text transformation of [data] into [this] CSS builder. */ private fun CssBuilder.textTransform(data: TextTransformData) { "font-weight" value data.weight "font-style" value data.style "font-variant" value data.variant "text-decoration" value data.decoration "text-transform" value data.case "color" value data.color } override fun visit(node: TextTransform): CharSequence { val tagName = when (node.data.script) { TextTransformData.Script.SUB -> "sub" TextTransformData.Script.SUP -> "sup" null -> "span" } return buildTag(tagName) { classNames( node.data.size?.asCSS, // e.g. 'size-small' class node.className, ) +node.children style { textTransform(node.data) } } } override fun visit(node: IconImage): CharSequence = buildTag("i") { val name = Escape.Html.escape(node.name) classNames("icon-image", "bi", "bi-$name") attribute("aria-hidden", "true") } override fun visit(node: InlineCollapse) = buildTag("span") { // Dynamic behavior is handled by JS. className("inline-collapse") attribute("data-full-text", buildMultiTag { +node.text }) attribute("data-collapsed-text", buildMultiTag { +node.placeholder }) attribute("data-collapsed", !node.isOpen) +if (node.isOpen) node.text else node.placeholder } // Invisible nodes override fun visit(node: PageMarginContentInitializer) = // In slides and paged documents, these elements are copied to each page in post-processing. buildTag("div") { classNames( "page-margin-content", "page-margin-${node.position.asCSS}", ) attribute("data-on-left-page", node.position.forLeftPage.asCSS) attribute("data-on-right-page", node.position.forRightPage.asCSS) +node.children } override fun visit(node: PageNumberReset) = buildTag("div") { className("page-number-reset") attribute("data-start", node.startFrom) hidden() } override fun visit(node: PageNumberFormatter) = buildTag("div") { className("page-number-formatter") attribute("data-format", node.format) hidden() } override fun visit(node: PageCounter) = // The current or total page number. // The actual number is filled by a script at runtime // (either slides.js or paged.js, depending on the document type). buildTag("span") { +"-" // The default placeholder in case it is not filled by a script (e.g. plain documents). className( when (node.target) { PageCounter.Target.CURRENT -> "current-page-number" PageCounter.Target.TOTAL -> "total-page-number" }, ) } override fun visit(node: LastHeading) = buildTag("span") { // Since pagination is performed at runtime, the last heading must be retrieved at runtime as well. className("last-heading") attribute("data-depth", node.depth) } override fun visit(node: SlidesConfigurationInitializer): CharSequence = buildTag("script") { hidden() // Injects properties that are read at runtime after the document is loaded. +buildString { append("window.slidesConfig = {") node.centerVertically?.let { append("center: $it,") } node.showControls?.let { append("showControls: $it,") } node.showNotes?.let { append("showNotes: $it,") } node.transition?.let { append("transitionStyle: '${it.style.asCSS}',") append("transitionSpeed: '${it.speed.asCSS}',") } append("};") } } // Additional behavior of base nodes // On top of the default behavior, an anchor ID is set, // and it could force an automatic page break if suitable. override fun visit(node: Heading): String { val tagBuilder = when { // When a heading has a depth of 0 (achievable only via functions), it is an invisible marker with an ID. node.isMarker -> { tagBuilder("div") { className("marker") hidden() } } // Regular headings. else -> { tagBuilder("h${node.depth}", node.text) } } // The heading tag itself. return tagBuilder .className("page-break".takeIf { context.shouldAutoPageBreak(node) }) .optionalAttribute( "id", // Generate an automatic identifier if allowed by settings. HtmlIdentifierProvider .of(renderer = this) .takeIf { context.options.enableAutomaticIdentifiers || node.customId != null } ?.getId(node), ).optionalAttribute("data-decorative", "".takeIf { node.isDecorative }) .withLocationLabel(node) .build() } // On top of the base behavior, a blockquote can have a type and an attribution. override fun visit(node: BlockQuote) = buildTag("blockquote") { // If the quote has a type (e.g. TIP), // the whole quote is marked as a 'tip' blockquote // and a localized label is shown (e.g. 'Tip:' for English). node.type?.asCSS?.let { type -> className(type) // The type is associated to a localized label // only if the documant language is set and the set language is supported. context.localizeOrNull(key = type)?.let { localizedLabel -> // The localized label is set as a CSS variable. // Themes can customize label appearance and formatting. style { "--quote-type-label" value "'$localizedLabel'" } // The quote is marked as labeled to allow further customization. attribute("data-labeled", "") } } // If the quote has a type, the first child must be a paragraph, because the label is rendered as ::before. if (node.type != null && node.children.firstOrNull() !is Paragraph) { +tagBuilder("p").acceptEmpty().build() } +node.children node.attribution?.let { +tagBuilder("p", it).className("attribution").build() } } // Quarkdown introduces table captions, also numerated. override fun visit(node: Table) = super .tableBuilder(node) .apply { numberedCaption( node, captionTagName = "caption", positionProvider = { tables }, ) }.build() override fun visit(node: Code): String { val block = super.visit(node) // If the code is numbered or has a caption, it is wrapped in a figure. if (node.caption == null && node.getLocationLabel(context) == null) { return block } return buildTag("figure") { +block numberedCaption(node, positionProvider = { codeBlocks }) } } // A code span can contain additional content, such as a color preview. override fun visit(node: CodeSpan): String { val codeTag = super.visit(node) // The code is wrapped to allow additional content. return buildTag("span") { className("codespan-content") +codeTag when (val content = node.content) { null -> {} // No additional content. is CodeSpan.ColorContent -> { // If the code contains a color code, show the color preview. +buildTag("span") { style { "background-color" value content.color } className("color-preview") } } } } } // List item variants. override fun visit(variant: FocusListItemVariant): HtmlTagBuilder.() -> Unit = { if (variant.isFocused) { className("focused") } } override fun visit(variant: LocationTargetListItemVariant): HtmlTagBuilder.() -> Unit = { withLocationLabel(variant.target) } override fun visit(variant: TableOfContentsItemVariant): HtmlTagBuilder.() -> Unit = { attribute( "data-target-id", HtmlIdentifierProvider.of(this@QuarkdownHtmlNodeRenderer).getId(variant.item.target), ) attribute("data-depth", variant.item.depth.toString()) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/node/SidebarRenderer.kt ================================================ package com.quarkdown.rendering.html.node import com.quarkdown.core.ast.attributes.id.getId import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.block.toc.convertTableOfContentsToListNode import com.quarkdown.core.context.Context import com.quarkdown.rendering.html.HtmlIdentifierProvider private const val MAX_DEPTH = 3 /** * Renderer of the sidebar content, loaded from the document's table of contents, * to be injected into the HTML template. */ object SidebarRenderer { /** * Renders the sidebar content. * @return rendered sidebar content */ fun render(context: Context): CharSequence { val toc = context.attributes.tableOfContents ?: return "" val renderer = QuarkdownHtmlNodeRenderer(context) val view = TableOfContentsView(maxDepth = MAX_DEPTH) val list = convertTableOfContentsToListNode( view, renderer, items = toc.items, linkUrlMapper = { item -> "#" + HtmlIdentifierProvider.of(renderer).getId(item.target) }, ) return list.accept(renderer) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/pdf/HtmlPdfExportOptions.kt ================================================ package com.quarkdown.rendering.html.pdf import java.io.File /** * Options for exporting PDF files from HTML via [PuppeteerNodeModule]. * @param nodeJsPath path to Node.js executable, or `null` for default * @param npmPath path to NPM executable, or `null` for default * @param noSandbox whether to disable Chrome sandbox for PDF export from HTML. Potentially unsafe */ data class HtmlPdfExportOptions( val outputDirectory: File, val nodeJsPath: String, val npmPath: String, val noSandbox: Boolean = false, ) ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/pdf/HtmlPdfExporter.kt ================================================ package com.quarkdown.rendering.html.pdf import com.quarkdown.core.log.Log import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NodeModuleNotInstalledException import com.quarkdown.interaction.executable.NpmWrapper import java.io.File /** * Exports a PDF from a directory with an `index.html` root file. * This is done via the Puppeteer library, invoked through Node.js. * @param options options that affect the export process * @see NodeJsWrapper * @see NpmWrapper */ class HtmlPdfExporter( private val options: HtmlPdfExportOptions, ) { /** * Exports a PDF from the given source directory. * @param sourcesDirectory the directory containing the HTML source files * @param out the output file for the generated PDF */ fun export( sourcesDirectory: File, out: File, ) { val node = NodeJsWrapper(path = options.nodeJsPath, workingDirectory = out.parentFile) val npm = NpmWrapper(path = options.npmPath) val script = PuppeteerPdfGeneratorScript( sourcesDirectory, out, node, npm, options.noSandbox, ) try { script.launch() } catch (e: NodeModuleNotInstalledException) { Log.error(e.message!!) } } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/pdf/PdfHtmlPostRendererDecorator.kt ================================================ package com.quarkdown.rendering.html.pdf import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.pipeline.output.BinaryOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.visitor.copy import com.quarkdown.core.pipeline.output.visitor.saveTo import com.quarkdown.core.rendering.PostRenderer import com.quarkdown.rendering.html.post.HtmlPostRenderer import java.io.File /** * Decorator for [HtmlPostRenderer] that generates a PDF file from the HTML output via Puppeteer. * @param postRenderer the original [HtmlPostRenderer] to be decorated * @param options options for the PDF export process */ class PdfHtmlPostRendererDecorator( private val postRenderer: HtmlPostRenderer, private val options: HtmlPdfExportOptions, ) : PostRenderer by postRenderer { override fun generateResources(rendered: CharSequence): Set<OutputResource> { val resources = postRenderer.generateResources(rendered) val tempDirectory = kotlin.io.path .createTempDirectory(prefix = "quarkdown-pdf") .toFile() val sourcesDirectory: File = OutputResourceGroup("sources", resources).saveTo(tempDirectory) val outName = postRenderer.context.subdocument.getOutputFileName(postRenderer.context) val out: File = tempDirectory.resolve("$outName.pdf") HtmlPdfExporter(options).export(sourcesDirectory, out) // In order to comply with the pipeline's contract, the output PDF is wrapped in an OutputResource. // It is deleted along with its temporary directory, and will be recreated in the output directory // by the pipeline's final process. return out .takeIf { it.exists() } ?.let(BinaryOutputArtifact::fromFile) .also { tempDirectory.deleteRecursively() } ?.let(::setOf) ?: emptySet() } override fun wrapResources( name: String, resources: Set<OutputResource>, ): OutputResource { // Single output file. resources.singleOrNull()?.let { return it.copy(name = "$name.pdf") } // Multiple output files (e.g. subdocuments). return OutputResourceGroup( name = name, resources = resources, ) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/pdf/PuppeteerNodeModule.kt ================================================ package com.quarkdown.rendering.html.pdf import com.quarkdown.interaction.executable.NodeModule /** * The [Puppeteer](https://pptr.dev) Node.js module. */ object PuppeteerNodeModule : NodeModule("puppeteer") ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/pdf/PuppeteerPdfGeneratorScript.kt ================================================ package com.quarkdown.rendering.html.pdf import com.quarkdown.core.log.Log import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NodeNpmHelper import com.quarkdown.interaction.executable.NpmWrapper import com.quarkdown.server.LocalFileWebServer import com.quarkdown.server.withScanner import java.io.File /** * The starting port to attempt to start the server on. * It is incremented until a free port is found. */ private const val STARTING_SERVER_PORT = 8096 /** * Script-like generator of a PDF from HTML through Puppeteer via Node.js. * @param sourcesDirectory directory containing the `index.html` file * @param out output PDF file to be written * @param node Node.js executable wrapper * @param npm NPM executable wrapper * @param noSandbox whether to disable Chrome sandbox for PDF export */ class PuppeteerPdfGeneratorScript( private val sourcesDirectory: File, private val out: File, private val node: NodeJsWrapper, private val npm: NpmWrapper, private val noSandbox: Boolean = false, ) { private var port: Int? = null /** * Launches Puppeteer to convert the webpage from [sourcesDirectory] into a PDF saved at [out]. * Blocking call. */ fun launch() = NodeNpmHelper(node, npm).launch(PuppeteerNodeModule) { launchServer() } private fun launchServer() { LocalFileWebServer(sourcesDirectory) .withScanner() .attemptStartUntilPortAvailable(STARTING_SERVER_PORT) { server, port -> this.port = port Log.info("PDF server is ready on port $port. Please wait...") try { runScript() Log.info("PDF generated successfully.") } catch (e: Exception) { Log.error("Failed to export PDF: ${e.message}") Log.debug(e) } finally { server.stop() } } } private fun runScript() { requireNotNull(port) { "PDF server port is not set" } val script = javaClass.getResourceAsStream("/pdf/pdf.js")!! val url = "http://localhost:$port/?print-pdf" node.eval(script.reader(), out.absolutePath, url, noSandbox.toString()) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/HtmlOnlyPostRenderer.kt ================================================ package com.quarkdown.rendering.html.post import com.quarkdown.core.context.Context import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.rendering.PostRenderer import com.quarkdown.rendering.html.node.SidebarRenderer import com.quarkdown.rendering.html.post.document.HtmlDocumentBuilder /** * A subset of [HtmlPostRenderer] that generates only the HTML output resources without any additional resources. * * It supports out of the box: * - RevealJS for slides rendering; * - PagedJS for page-based rendering (e.g. books); * - KaTeX for math rendering; * - HighlightJS for code highlighting. * * @param context the rendering context * @param name the name of the HTML output resource, without extension * @param relativePathToRoot the relative path to follow to get from the HTML resources generated by this post-renderer * to the root (the location where the main HTML file is located, alongside `script`, `theme`, etc.) */ class HtmlOnlyPostRenderer( private val context: Context, private val name: String = "index", private val relativePathToRoot: String, ) : PostRenderer { // HTML requires local media to be resolved from the file system. override val preferredMediaStorageOptions: MediaStorageOptions = ReadOnlyMediaStorageOptions(enableLocalMediaStorage = true) override fun wrap(content: CharSequence): CharSequence = HtmlDocumentBuilder( context, relativePathToRoot, sidebarContent = SidebarRenderer.render(context), ).build(content) /** * Generates a single HTML output resource with the given name and the rendered content, and no additional resources. */ override fun generateResources(rendered: CharSequence): Set<OutputResource> = buildSet { this += TextOutputArtifact( name = name, content = rendered, type = ArtifactType.HTML, ) } override fun wrapResources( name: String, resources: Set<OutputResource>, ) = resources.first() } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/HtmlPostRenderer.kt ================================================ package com.quarkdown.rendering.html.post import com.quarkdown.core.context.Context import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.orDefault import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.rendering.PostRenderer import com.quarkdown.rendering.html.post.resources.MediaPostRendererResource import com.quarkdown.rendering.html.post.resources.PostRendererResource import com.quarkdown.rendering.html.post.resources.ProxiedPostRendererResource import com.quarkdown.rendering.html.post.resources.ScriptPostRendererResource import com.quarkdown.rendering.html.post.resources.SearchIndexPostRendererResource import com.quarkdown.rendering.html.post.resources.ThemePostRendererResource import com.quarkdown.rendering.html.search.SearchIndexGenerator // Default theme components to use if not specified by the user. private val DEFAULT_THEME = DocumentTheme( color = "paperwhite", layout = "latex", ) /** * A [PostRenderer] that wraps content into a full HTML document. This includes all the features of [HtmlOnlyPostRenderer], plus: * - Theme components * - Runtime scripts * - Media resources * * @param context the [Context] of the document being rendered * @param relativePathToRoot relative path from the current document to the root document, used to correctly link resources * @param base the base [HtmlOnlyPostRenderer] to delegate HTML generation to * @param resourcesProvider supplier of the set of [PostRendererResource] to include in the output. Delegation to [base] is always included */ class HtmlPostRenderer( val context: Context, relativePathToRoot: String = ".", private val base: HtmlOnlyPostRenderer = HtmlOnlyPostRenderer( context, relativePathToRoot = relativePathToRoot, ), private val resourcesProvider: () -> Set<PostRendererResource> = { setOfNotNull( ThemePostRendererResource( theme = context.documentInfo.theme.orDefault(DEFAULT_THEME), locale = context.documentInfo.locale, ), ScriptPostRendererResource(), MediaPostRendererResource(context.mediaStorage), if (context.documentInfo.type == DocumentType.DOCS) { SearchIndexPostRendererResource(SearchIndexGenerator.generate(context.sharedSubdocumentsData)) } else { null }, ) }, ) : PostRenderer by base { override fun generateResources(rendered: CharSequence): Set<OutputResource> = buildSet { // The HTML content is always included, regardless of the other options. val resources = resourcesProvider() + ProxiedPostRendererResource(base) resources.forEach { it.includeTo(this, rendered) } } override fun wrapResources( name: String, resources: Set<OutputResource>, ) = OutputResourceGroup(name, resources) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/HtmlSubdocumentPostRenderer.kt ================================================ package com.quarkdown.rendering.html.post import com.quarkdown.core.context.Context import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.rendering.PostRenderer import com.quarkdown.rendering.html.post.resources.MediaPostRendererResource /** * A [PostRenderer] for subdocuments, which generates a group (directory) containing a `index.html` resource, * and a media directory if needed, and redirects scripts, themes, and other resources to the root's ([HtmlPostRenderer]) resources. * * @param base the base [HtmlPostRenderer] to delegate rendering to */ class HtmlSubdocumentPostRenderer( private val base: HtmlPostRenderer, ) : PostRenderer by base { /** * Note: Quarkdown currently exports subdocuments at a single level of nesting, hence the relative path to root is hardcoded to `..`. * This may change in the future. */ constructor(context: Context) : this( HtmlPostRenderer( context, relativePathToRoot = "..", resourcesProvider = { setOf(MediaPostRendererResource(context.mediaStorage)) }, ), ) override fun generateResources(rendered: CharSequence): Set<OutputResource> = setOf( OutputResourceGroup( name = base.context.subdocument.getOutputFileName(base.context), resources = base.generateResources(rendered), ), ) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/document/CssFontFacesImporter.kt ================================================ package com.quarkdown.rendering.html.post.document import com.quarkdown.core.media.LocalMedia import com.quarkdown.core.media.MediaVisitor import com.quarkdown.core.media.RemoteMedia import com.quarkdown.core.media.storage.ReadOnlyMediaStorage import com.quarkdown.core.media.storage.StoredMedia import com.quarkdown.core.misc.font.FontFamily private fun fontFaceSnippet( name: String, src: String, ): String = "@font-face { font-family: '$name'; src: $src; }" /** * Generator of `@font-face` and `@import` CSS rules for a list of [FontFamily], * to be used in HTML post-rendering. It supports system, local, and remote media sources. * @param families the list of [FontFamily] to generate CSS rules for * @param mediaStorage the media storage to resolve media paths against. * In case the storage contains the media, the stored media will be referenced rather than the original resource. */ class CssFontFacesImporter( private val families: List<FontFamily>, private val mediaStorage: ReadOnlyMediaStorage, ) { private fun toSnippet(family: FontFamily): String = when (family) { // local(name) for system fonts. is FontFamily.System -> fontFaceSnippet(family.id, "local('${family.name}')") // url(path) for local or remote media. // If the media is stored, it will use the stored media path. is FontFamily.Media -> { val storedMedia: StoredMedia? = mediaStorage.resolve(family.path) family.media.accept(CssFontFaceImporterMediaVisitor(family, storedMedia)) } // @import for Google Fonts. is FontFamily.GoogleFont -> { "@import url('${family.path}&display=swap');" } } /** * @return one CSS import snippet per [FontFamily] in the list. */ fun toSnippets(): List<String> = families.map(::toSnippet) } private class CssFontFaceImporterMediaVisitor( private val family: FontFamily, private val storedMedia: StoredMedia?, ) : MediaVisitor<String> { override fun visit(media: LocalMedia) = fontFaceSnippet( family.id, "url('${storedMedia?.path ?: media.file.absolutePath}')", ) override fun visit(media: RemoteMedia) = fontFaceSnippet( family.id, "url('${storedMedia?.path ?: media.url}')", ) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/document/HtmlDocumentBuilder.kt ================================================ package com.quarkdown.rendering.html.post.document import com.quarkdown.core.ast.attributes.presence.hasCode import com.quarkdown.core.ast.attributes.presence.hasMath import com.quarkdown.core.ast.attributes.presence.hasMermaidDiagram import com.quarkdown.core.context.Context import com.quarkdown.core.document.DocumentType import com.quarkdown.core.util.Escape import kotlinx.html.BODY import kotlinx.html.HEAD import kotlinx.html.InputType import kotlinx.html.aside import kotlinx.html.body import kotlinx.html.div import kotlinx.html.footer import kotlinx.html.head import kotlinx.html.header import kotlinx.html.html import kotlinx.html.i import kotlinx.html.id import kotlinx.html.input import kotlinx.html.kbd import kotlinx.html.lang import kotlinx.html.link import kotlinx.html.main import kotlinx.html.meta import kotlinx.html.nav import kotlinx.html.script import kotlinx.html.stream.appendHTML import kotlinx.html.stream.createHTML import kotlinx.html.style import kotlinx.html.title import kotlinx.html.unsafe /** * Builds a full HTML document wrapping the rendered content, replacing the old template-based approach. * Uses kotlinx.html DSL to programmatically construct the document structure. * * @param context the rendering context containing document metadata, attributes, and configuration * @param relativePathToRoot the relative path from the current document to the root directory, * used to correctly reference shared resources (scripts, themes, etc.) * @param sidebarContent the pre-rendered sidebar HTML content (table of contents) */ class HtmlDocumentBuilder( private val context: Context, private val relativePathToRoot: String, private val sidebarContent: CharSequence, ) { private val document = context.documentInfo private val pageFormats = document.layout.getPageFormatsWithDefault(document.type.defaultPageFormat) /** * Builds the full HTML document wrapping the given [content]. * @param content the rendered body content to embed * @return the complete HTML document as a [CharSequence] */ fun build(content: CharSequence): CharSequence = buildString { appendLine("<!DOCTYPE html>") appendHTML(prettyPrint = true, xhtmlCompatible = false).html { document.locale?.tag?.let { lang = it } head { buildHead() } body(classes = bodyClasses()) { buildBody(content) } } } private fun HEAD.buildHead() { documentMetadata() viewport() quarkdownMeta() title(document.name ?: "Quarkdown") quarkdownScript() pagedScripts() slidesScripts() iconLibrary() themeStylesheet() codeScripts() mathScripts() mermaidScripts() documentStyle() documentTypeInitScript() sidebarTemplate() } /** Emits generator, charset, and optional description/keywords/author meta tags. */ private fun HEAD.documentMetadata() { meta(name = "generator", content = "Quarkdown") meta(charset = "UTF-8") document.description ?.let { meta(name = "description", content = it) } document.keywords .takeIf { it.isNotEmpty() } ?.joinToString() ?.let { meta(name = "keywords", content = it) } document.authors .takeIf { it.isNotEmpty() } ?.joinToString { it.name } ?.let { meta(name = "author", content = it) } } /** Emits the viewport meta tag, disabling user scaling for slides. */ private fun HEAD.viewport() { val viewportContent = buildString { append("width=device-width, initial-scale=1.0") if (document.type == DocumentType.SLIDES) { append(", maximum-scale=1.0, user-scalable=no") } } meta(name = "viewport", content = viewportContent) } /** Emits Quarkdown-specific meta tags for root path resolution and, for docs, the search index. */ private fun HEAD.quarkdownMeta() { meta(name = "quarkdown:root-path", content = relativePathToRoot) if (document.type == DocumentType.DOCS) { meta(name = "quarkdown:search-index", content = "$relativePathToRoot/search-index.json") } } /** Loads the main Quarkdown script and initializes the capabilities object. */ private fun HEAD.quarkdownScript() { script(src = "$relativePathToRoot/script/quarkdown.js") {} script { unsafe { raw("const capabilities = window.quarkdownCapabilities") } } } /** Loads the Paged.js polyfill for paged documents. No-op for other document types. */ private fun HEAD.pagedScripts() { if (document.type != DocumentType.PAGED) return script { unsafe { raw("window.PagedConfig = {auto: false};") } } script(src = "https://unpkg.com/pagedjs@0.4.3/dist/paged.polyfill.js") {} } /** Loads Reveal.js scripts and styles for slide documents. No-op for other document types. */ private fun HEAD.slidesScripts() { if (document.type != DocumentType.SLIDES) return script(src = "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/5.2.1/reveal.js") {} script(src = "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/5.2.1/plugin/notes/notes.js") {} link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/5.2.1/reset.css") link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/5.2.1/reveal.css") link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/5.2.1/theme/white.css") } private fun HEAD.iconLibrary() { link( rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css", ) } private fun HEAD.themeStylesheet() { link(rel = "stylesheet", href = "$relativePathToRoot/theme/theme.css") } /** Loads Highlight.js and its plugins for code highlighting. No-op if the document contains no code. */ private fun HEAD.codeScripts() { if (!context.attributes.hasCode) return script(src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js") {} script(src = "https://cdnjs.cloudflare.com/ajax/libs/highlightjs-line-numbers.js/2.9.0/highlightjs-line-numbers.min.js") {} script(src = "https://unpkg.com/highlightjs-copy/dist/highlightjs-copy.min.js") {} link(rel = "stylesheet", href = "https://unpkg.com/highlightjs-copy/dist/highlightjs-copy.min.css") script { unsafe { raw("capabilities.code = true;") } } } /** Loads KaTeX and emits user-defined TeX macros. No-op if the document contains no math. */ private fun HEAD.mathScripts() { if (!context.attributes.hasMath) return link(rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css") { attributes["integrity"] = "sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" attributes["crossorigin"] = "anonymous" } script(src = "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js") { attributes["defer"] = "true" attributes["integrity"] = "sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6" attributes["crossorigin"] = "anonymous" } script { unsafe { raw( buildString { appendLine("capabilities.math = true;") appendLine() append("window.texMacros = {") context.documentInfo.tex.macros.forEach { (key, value) -> append('"') append(Escape.JavaScript.escape(key)) append("\": \"") append(Escape.JavaScript.escape(value)) append("\",") } append("}") }, ) } } } /** Loads the Mermaid library for diagram rendering. No-op if the document contains no diagrams. */ private fun HEAD.mermaidScripts() { if (!context.attributes.hasMermaidDiagram) return script(src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js") {} script { unsafe { raw("capabilities.mermaid = true;") } } } /** Embeds the document's CSS stylesheet, generated by [HtmlDocumentStylesheet]. */ private fun HEAD.documentStyle() { style { unsafe { raw(HtmlDocumentStylesheet(context, pageFormats).build()) } } } /** Emits the script that instantiates the document type handler (e.g. `PlainDocument`, `SlidesDocument`). */ private fun HEAD.documentTypeInitScript() { val docClassName = when (document.type) { DocumentType.PLAIN -> "PlainDocument" DocumentType.SLIDES -> "SlidesDocument" DocumentType.PAGED -> "PagedDocument" DocumentType.DOCS -> "DocsDocument" } script { unsafe { raw("prepare(new $docClassName());") } } } /** * Injects the sidebar (table of contents) wrapped in a `<template>` tag, * so the front-end script can clone and insert it at the appropriate location. */ private fun HEAD.sidebarTemplate() { val navContent = createHTML().nav("sidebar") { attributes["role"] = "doc-toc" unsafe { raw(sidebarContent.toString()) } } unsafe { raw("<template id=\"sidebar-template\">") raw(navContent) raw("</template>") } } /** Builds the CSS class string for `<body>`, including document type and optional multicolumn flag. */ private fun bodyClasses(): String = buildString { append("quarkdown quarkdown-") append(document.type.name.lowercase()) if (pageFormats.any { it.columnCount != null }) { append(" multicolumn") } } /** Dispatches to the appropriate body builder for the current document type. */ private fun BODY.buildBody(content: CharSequence) { when (document.type) { DocumentType.PLAIN -> plainBody(content) DocumentType.SLIDES -> slidesBody(content) DocumentType.PAGED -> pagedBody(content) DocumentType.DOCS -> docsBody(content) } } private fun BODY.plainBody(content: CharSequence) { aside("margin-area") { id = "margin-area-left" } main { unsafe { raw(content.toString()) } } aside("margin-area") { id = "margin-area-right" } } private fun BODY.slidesBody(content: CharSequence) { div("reveal") { div("slides") { unsafe { raw(content.toString()) } } } } private fun BODY.pagedBody(content: CharSequence) { div("paged-content-wrapper") { unsafe { raw(content.toString()) } } } private fun BODY.docsBody(content: CharSequence) { header { aside("margin-area") main { div("search-wrapper") { div("search-field") { i("bi bi-search") input(type = InputType.text) { id = "search-input" placeholder = "Search" attributes["autocomplete"] = "off" } kbd { +"/" } } } } aside("margin-area") } div("content-wrapper") { aside("margin-area") { id = "margin-area-left" div("position-top") div("position-middle") div("position-bottom") } main { unsafe { raw(content.toString()) } div { id = "footnote-area" div("footnote-rule") } div { id = "sibling-pages-button-area" } footer { div("position-left") div("position-center") div("position-right") } } aside("margin-area") { id = "margin-area-right" div("position-top") div("position-middle") div("position-bottom") } } } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/document/HtmlDocumentStylesheet.kt ================================================ package com.quarkdown.rendering.html.post.document import com.quarkdown.core.context.Context import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.layout.font.FontInfo import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.paragraph.ParagraphStyleInfo import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.rendering.html.css.CssPageSelectors import com.quarkdown.rendering.html.css.asCSS import com.quarkdown.rendering.html.css.stylesheet /** * Generates the CSS `<style>` content for an HTML document, including font-face imports, * CSS custom properties, and layout rules derived from the document's configuration. * * This class encapsulates all CSS/font concerns, keeping the [HtmlDocumentBuilder] focused * purely on HTML structure. * * @param context the rendering context containing document metadata, layout, and font configuration */ class HtmlDocumentStylesheet( private val context: Context, private val pageFormats: List<PageFormatInfo>, ) { private val document = context.documentInfo fun build(): String = buildString { appendLine(buildFonts()) appendLine(buildParagraphStyle(document.layout.paragraphStyle)) pageFormats.forEach { pageFormat -> appendLine(buildPageFormat(pageFormat)) } } /** * Builds `@font-face` declarations and font CSS custom properties. * Emitted once, outside the per-format loop, since fonts are document-global * and CSS custom properties are invalid inside `@page` at-rules. */ private fun buildFonts(): String = stylesheet { raw(fontFaceSnippets().joinToString("\n")) rule("body") { "--qd-main-custom-font" value mainFontFamily() "--qd-heading-custom-font" value headingFontFamily() "--qd-code-custom-font" value codeFontFamily() "--qd-main-font-size" value fontSizeCss() } } private fun buildParagraphStyle(paragraphStyle: ParagraphStyleInfo): String = stylesheet { rule("body") { "--qd-line-height" value paragraphStyle.lineHeight?.toString() "--qd-letter-spacing" value paragraphStyle.letterSpacing?.let { "${it}em" } "--qd-paragraph-vertical-margin" value paragraphStyle.spacing?.let { "${it}em" } } rule("p") { "--qd-paragraph-text-indent" value paragraphStyle.indent?.let { "${it}em" } } } /** * Builds the CSS stylesheet for a single [PageFormatInfo], depending on its scope ([PageFormatInfo.selector]) and properties. */ private fun buildPageFormat(format: PageFormatInfo): String { val isScoped = format.selector != null // For scoped selectors, expand into individual @page selectors. val pageSelectors = CssPageSelectors.toCss(format.selector) return stylesheet { for (selector in if (isScoped) pageSelectors else listOf("body")) { rule(selector) { "--qd-content-width" value format.pageWidth "--qd-column-count" value format.columnCount?.toString() if (format.alignment?.isLocal == true) { "--qd-horizontal-alignment-local" importantValue format.alignment "--qd-horizontal-alignment-global" importantValue "unset" "--qd-horizontal-alignment-list-items" importantValue "unset" } if (format.alignment?.isGlobal == true) { "--qd-horizontal-alignment-global" importantValue format.alignment "--qd-horizontal-alignment-local" importantValue format.alignment } format.contentBorderWidth?.let { "--qd-page-content-border-width" value it "--qd-page-content-border-style" value "solid" } format.contentBorderColor?.let { "--qd-page-content-border-color" value it "--qd-page-content-border-style" value "solid" } } } if (!isScoped) { rule( "body.quarkdown-plain.quarkdown-plain", "body.quarkdown-docs.quarkdown-docs", ) { "margin" value format.margin } rule("body.quarkdown-slides.quarkdown-slides .reveal") { "width" value format.pageWidth "height" value format.pageHeight } } for (selector in pageSelectors) { rule(selector) { if (format.pageWidth != null || format.pageHeight != null) { "size" value "${format.pageWidth?.asCSS ?: "auto"} ${format.pageHeight?.asCSS ?: "auto"}" } "margin" value (format.margin?.asCSS ?: if (document.type == DocumentType.PLAIN) "0" else null) } } } } /** * Extracts font family IDs from the document's font stack using the given [extractor], * returning them as a comma-separated CSS value (e.g. `'Roboto', 'Noto Sans'`). * Fonts are reversed so that later declarations take higher priority. */ private fun fontFamilyIds(extractor: (FontInfo) -> FontFamily?): String? = document.layout.fonts .reversed() .mapNotNull { extractor(it)?.id } .takeIf { it.isNotEmpty() } ?.joinToString(", ") { "'$it'" } private fun mainFontFamily(): String? = fontFamilyIds { it.mainFamily } private fun headingFontFamily(): String? = fontFamilyIds { it.headingFamily } private fun codeFontFamily(): String? = fontFamilyIds { it.codeFamily } private fun fontSizeCss(): String? = document.layout.fonts .lastOrNull { it.size != null } ?.size ?.asCSS /** Generates `@font-face` CSS snippets for all font families referenced by the document. */ private fun fontFaceSnippets(): List<String> { val allFamilies: List<FontFamily> = document.layout.fonts.flatMap { font -> listOfNotNull(font.mainFamily, font.headingFamily, font.codeFamily) } return CssFontFacesImporter(allFamilies, context.mediaStorage).toSnippets() } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/MediaPostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.media.storage.ReadOnlyMediaStorage import com.quarkdown.core.pipeline.output.OutputResource /** * A [PostRendererResource] that includes media (such as images) * from the document's media storage into the output, in its own directory. * * @param mediaStorage the storage containing media files referenced by the document */ class MediaPostRendererResource( private val mediaStorage: ReadOnlyMediaStorage, ) : PostRendererResource { override fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) { if (!mediaStorage.isEmpty) { resources += mediaStorage.toResource() } } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/PostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.pipeline.output.OutputResource /** * Strategy that determines what [OutputResource]s are exported as part of the HTML output. * * Implementations of this interface are responsible for collecting and adding * specific types of resources (e.g., themes, scripts, media) to the output. * * @see com.quarkdown.rendering.html.post.HtmlPostRenderer */ sealed interface PostRendererResource { /** * Collects resources and adds them to the output set. * @param resources the mutable set to add resources to * @param rendered the rendered HTML content, which may be inspected to determine required resources */ fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/ProxiedPostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.rendering.PostRenderer /** * A [PostRendererResource] that delegates resource generation to another [PostRenderer]. * * This enables composition of post-renderers by including resources * generated by a base renderer (e.g., [com.quarkdown.rendering.html.post.HtmlOnlyPostRenderer]) * as part of the current output. * * @param base the post-renderer whose generated resources will be included */ class ProxiedPostRendererResource( private val base: PostRenderer, ) : PostRendererResource { override fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) { resources += base.generateResources(rendered) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/ScriptPostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.LazyOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup /** * A [PostRendererResource] that includes runtime JavaScript files required by the HTML output. * * The `quarkdown.js` script is pre-bundled from TypeScript sources and provides client-side functionality * for interactive Quarkdown features. */ class ScriptPostRendererResource : PostRendererResource { override fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) { resources += OutputResourceGroup( name = "script", resources = retrieveScriptComponentsArtifacts(), ) } /** * @return a set that contains an output artifact for each required script component */ private fun retrieveScriptComponentsArtifacts(): Set<OutputResource> = setOf( LazyOutputArtifact.internal( resource = "/render/script/quarkdown.js", // The name is not used here, as this artifact will be concatenated to others in generateResources. name = "quarkdown", type = ArtifactType.JAVASCRIPT, ), ) } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/SearchIndexPostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.rendering.html.search.SearchIndex import kotlinx.serialization.json.Json /** * A [PostRendererResource] that outputs a search index as a JSON file. * * The generated `search-index.json` file is placed in the output directory * and can be fetched by client-side JavaScript to provide documentation search * without requiring a server. * * @param index the search index to serialize * @see com.quarkdown.rendering.html.search.SearchIndex * @see com.quarkdown.rendering.html.search.SearchIndexGenerator */ class SearchIndexPostRendererResource( private val index: SearchIndex, ) : PostRendererResource { override fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) { resources += TextOutputArtifact( name = "search-index", content = Json.encodeToString(index), type = ArtifactType.JSON, ) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/post/resources/ThemePostRendererResource.kt ================================================ package com.quarkdown.rendering.html.post.resources import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.localization.Locale import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.LazyOutputArtifact import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact /** * A [PostRendererResource] that includes CSS theme components for styling the HTML output. * * Theme components include: * - Global styles (always included) * - Layout-specific styles (e.g., `latex`) * - Color scheme styles (e.g., `paperwhite`) * - Locale-specific styles (e.g., for CJK typefaces) * * A `theme.css` manifest file is generated that imports all active theme components. * * @param theme the document theme specifying color and layout preferences * @param locale the optional locale for locale-specific styling (e.g., Chinese typefaces) */ class ThemePostRendererResource( private val theme: DocumentTheme, private val locale: Locale?, ) : PostRendererResource { override fun includeTo( resources: MutableSet<OutputResource>, rendered: CharSequence, ) { resources += OutputResourceGroup( name = "theme", resources = retrieveThemeComponentsArtifacts(theme, locale), ) } private fun getFullResourcePath(resourceSubPath: String): String = "/render/theme/$resourceSubPath.css" /** * Pushes a new output artifact to the set if it exists. * @param resourceName name of the resource * @param resourcePath path of the resource starting from the theme folder, without extension */ private fun MutableSet<OutputResource>.artifact( resourceName: String, resourcePath: String = resourceName, ) { val artifact = LazyOutputArtifact.internalOrNull( resource = getFullResourcePath(resourcePath), // The name is not used here, as this artifact will be concatenated to others in generateResources. name = resourceName, type = ArtifactType.CSS, ) if (artifact != null) { this += artifact } } /** * @param theme theme to get the artifacts for * @return a set that contains an output artifact for each non-null theme component of [theme] * (e.g. color scheme, layout format, ...) */ private fun retrieveThemeComponentsArtifacts( theme: DocumentTheme?, locale: Locale?, ): Set<OutputResource> = buildSet { // Pushing theme components. artifact("global") theme?.layout?.let { artifact(it, "layout/$it") } theme?.color?.let { artifact(it, "color/$it") } // In case the active locale features its own theme components, add them as well. // For example, Chinese typefaces (#105). locale?.shortTag?.let { artifact(it, "locale/$it") } // A theme.css file contains only @import statements for each theme component // in order to link them into a single file that can be easily included in the main HTML file. this += TextOutputArtifact( name = "theme", content = joinToString(separator = "\n") { "@import url('${it.name}.css');" }, type = ArtifactType.CSS, ) } } ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/search/SearchIndex.kt ================================================ package com.quarkdown.rendering.html.search import kotlinx.serialization.Serializable /** * A search index for client-side documentation search. * This structure is serialized to JSON and loaded by the browser * to provide search functionality without a server. * @param entries the searchable documents in the index */ @Serializable data class SearchIndex( val entries: List<SearchEntry>, ) /** * A searchable document entry in the search index. * @param url relative URL to the document * @param title document title, used as primary search field and displayed in results * @param description brief summary of the document content * @param keywords additional terms to improve search relevance * @param headings section headings within the document, allowing navigation to specific sections */ @Serializable data class SearchEntry( val url: String, val title: String?, val description: String?, val keywords: List<String>, val content: String, val headings: List<SearchHeading>, ) /** * A heading within a document, enabling search results to link directly to sections. * @param anchor the HTML anchor ID for the heading (used in URL fragment) * @param text the heading text content * @param level the heading level (1-6, corresponding to h1-h6) */ @Serializable data class SearchHeading( val anchor: String, val text: String, val level: Int, ) ================================================ FILE: quarkdown-html/src/main/kotlin/com/quarkdown/rendering/html/search/SearchIndexGenerator.kt ================================================ package com.quarkdown.rendering.html.search import com.quarkdown.core.context.Context import com.quarkdown.core.context.subdocument.SubdocumentsData import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.graph.Graph import com.quarkdown.core.util.node.toPlainText import com.quarkdown.rendering.html.HtmlIdentifierProvider import com.quarkdown.rendering.plaintext.node.PlainTextNodeRenderer /** * Generates a [SearchIndex] from the subdocument graph of a multi-document project. * The generated index is intended to be serialized to JSON and used for client-side search. */ object SearchIndexGenerator { /** * Generates a search index from the given subdocument graph. * Each subdocument becomes a [SearchEntry] containing its URL, metadata, and headings. * @param graph the subdocument graph representing the documentation structure * @return a [SearchIndex] containing all searchable entries */ fun generate(graph: SubdocumentsData<out Graph<Subdocument>>): SearchIndex { val subdocuments = graph.graph.vertices return SearchIndex( entries = subdocuments.mapNotNull { subdocument -> val context = graph.withContexts[subdocument] ?: return@mapNotNull null SearchEntry( url = "/" + if (subdocument is Subdocument.Root) "" else subdocument.getOutputFileName(context), title = context.documentInfo.name, description = context.documentInfo.description, keywords = context.documentInfo.keywords, content = context.attributes.root ?.accept(PlainTextNodeRenderer(context)) ?.trimEnd() ?.toString() ?: "", headings = getHeadings(context), ) }, ) } private fun flatten(item: TableOfContents.Item): Sequence<TableOfContents.Item> = sequenceOf(item) + item.subItems.asSequence().flatMap(::flatten) private fun getHeadings(context: Context): List<SearchHeading> { val toc = context.attributes.tableOfContents ?: return emptyList() return toc.items .asSequence() .flatMap(::flatten) .map { item -> SearchHeading( anchor = item.target.accept(HtmlIdentifierProvider.of(renderer = null)), text = item.text.toPlainText(), level = item.depth, ) }.toList() } } ================================================ FILE: quarkdown-html/src/main/resources/pdf/pdf.js ================================================ const outputFile = process.argv[1]; const url = process.argv[2]; const noSandbox = process.argv[3] === 'true'; console.log('outputFile: ' + outputFile); console.log('url: ' + url); const puppeteer = require('puppeteer'); function createArgs() { const args = [ '--disable-gpu', ] if (noSandbox) { args.push('--no-sandbox'); } return args; } (async () => { const args = createArgs(); console.log('Running with args: ' + args); const browser = await puppeteer.launch({ args: args, headless: 'shell', }); const page = await browser.newPage(); console.log('Connecting to ' + url); await page.goto(url); console.log('Connected. Waiting for page content.'); await page.content(); console.log('Connected. Waiting for page to be ready.'); await page.waitForFunction('window.isReady()'); const body = await page.$('body'); // Plain documents render as a single-page PDF. const isSinglePage = await body.evaluate(bodyElement => bodyElement.classList.contains('quarkdown-plain')); const singlePageHeightPadding = 100; // Additional height added to single-page PDFs. If not enough, an additional page will be incorrectly generated. const singlePageHeightMultiplier = 1.03; const pdfOptions = { path: outputFile, printBackground: true, preferCSSPageSize: true, ...( isSinglePage ? {height: (await getClientHeight(body)) * singlePageHeightMultiplier + singlePageHeightPadding + 'px'} : {} ), }; await page.pdf(pdfOptions); await browser.close(); })(); async function getClientHeight(body) { return body.evaluate(bodyElement => bodyElement.clientHeight); } ================================================ FILE: quarkdown-html/src/main/scss/color/beaver.scss ================================================ /* Beamer's Beaver color theme */ @import url('https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css'); :root { --qd-primary-color: rgb(187, 39, 26); --primary-darker: rgb(149, 29, 18); --secondary: rgb(236, 236, 236); --tertiary: rgb(216, 216, 216); --qd-main-color: rgb(10, 10, 10); --qd-background-color: white; --qd-link-color: var(--qd-primary-color); --qd-color-on-primary: var(--qd-background-color); --qd-callout-background-color: rgb(234, 233, 247); --qd-callout-content-foreground-color: var(--qd-main-color); --qd-callout-title-foreground-color: rgb(83, 81, 152); --qd-mermaid-node-background-color: var(--qd-background-color); --qd-mermaid-node-text-color: var(--qd-main-color); } .quarkdown { h1, h2 { color: var(--qd-primary-color) !important; } h1:not(.box h1) { background-color: var(--secondary); } .box.callout { --qd-box-header-background-color: color-mix(in srgb, var(--qd-callout-title-foreground-color) 7%, var(--qd-callout-background-color)); .box-content li::marker { color: var(--qd-callout-foreground-color); } &:has(.box-content h1) { background-color: var(--secondary); } } .color-preview { border-color: var(--secondary) !important; } .page-margin-bottom-center > * { background-color: var(--tertiary); color: var(--qd-primary-color); &:first-child { background-color: var(--qd-primary-color); color: white; } &:nth-child(even) { background-color: var(--secondary); } } } ================================================ FILE: quarkdown-html/src/main/scss/color/darko.scss ================================================ @import url('https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css'); :root { --qd-color-scheme: dark; --qd-primary-color: #224A51; --qd-background-color: #242528; --qd-main-color: #DADAEB; --qd-link-color: #5FC7C7; --qd-nested-navigation-line-color: rgba(255, 255, 255, 0.1); --qd-box-header-background-color: rgba(23, 53, 56, 0.35); --qd-callout-background-color: var(--qd-primary-color); --qd-callout-foreground-color: var(--qd-color-on-primary); --qd-tip-background-color: rgb(60, 114, 74); --qd-tip-title-foreground-color: rgb(210, 250, 214); --qd-note-background-color: rgb(52, 79, 117); --qd-note-title-foreground-color: rgb(197, 222, 255); --qd-warning-background-color: rgb(239, 203, 99); --qd-warning-title-foreground-color: #333; --qd-important-title-foreground-color: #FF7373; --qd-color-on-primary: var(--qd-main-color); --qd-mermaid-node-background-color: #ECECFF; --qd-mermaid-node-text-color: var(--qd-background-color); --qd-docs-header-background-color: #2E3136; --qd-docs-separator-border-color: #393c40; --qd-docs-search-focused-border: var(--qd-link-color); } .quarkdown { hr { opacity: 0.5; } pre :has(code) { --shadow-opacity: 0.08; box-shadow: 0 5px 15px rgba(0, 0, 0, var(--shadow-opacity)); } .quarkdown-slides pre { --shadow-opacity: 0.1; } .warning { --qd-box-header-background-color: rgba(0, 0, 0, .1); &.box { --qd-warning-content-foreground-color: var(--qd-warning-title-foreground-color); } } blockquote.warning { --box-header-foreground-color: var(--qd-warning-background-color); } .page-margin-bottom-center { border-color: var(--qd-link-color); } .mermaid .plot .line-plot-3 path { stroke: salmon; } #search-results { border: 1px solid var(--qd-docs-search-border-color); } .search-result-title { color: var(--qd-link-color); } } ================================================ FILE: quarkdown-html/src/main/scss/color/galactic.scss ================================================ @use "darko"; :root { --qd-primary-color: rgb(178, 199, 255); --qd-main-color: rgb(208, 210, 217); --qd-heading-color: white; --qd-link-color: var(--qd-primary-color); --qd-background-color: rgb(23, 24, 28); --qd-warning-title-foreground-color: var(--qd-warning-background-color); --qd-mermaid-node-background-color: white; --qd-docs-header-background-color: rgb(35, 38, 47); --qd-docs-separator-border-color: var(--qd-docs-header-background-color); --qd-docs-search-border-color: rgb(53, 56, 65); --qd-docs-search-focused-border: var(--qd-primary-color); } .quarkdown { .box:not(.error) { background: color-mix(in srgb, var(--box-background-color) 5%, transparent); --qd-box-header-background-color: color-mix(in srgb, var(--box-header-foreground-color) 6%, transparent); border-style: solid; border-color: var(--box-header-foreground-color); border-width: 1px 3px 1px 2px; &.warning { --qd-warning-content-foreground-color: var(--qd-main-color); } } #search-results { --qd-docs-separator-border-color: var(--qd-main-color-muted); } } ================================================ FILE: quarkdown-html/src/main/scss/color/paperwhite.scss ================================================ @import url('https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.css'); :root { --qd-primary-color: rgb(220, 220, 220); --qd-background-color: white; --qd-main-color: hsl(0, 5%, 10%); --qd-link-color: hsl(0, 100%, 33%); --qd-nested-navigation-line-color: rgba(0, 0, 0, .2); --qd-color-on-primary: var(--qd-main-color); --qd-mermaid-node-background-color: var(--qd-background-color); --qd-docs-header-background-color: rgb(246, 247, 249); --qd-docs-separator-border-color: rgb(237, 238, 243); --qd-docs-search-border-color: rgb(193, 195, 200); --qd-docs-search-focused-border: var(--qd-main-color); } .quarkdown { blockquote { border-color: rgba(0, 0, 0, 0.2); } pre code { background-color: transparent; border: 1px solid var(--qd-main-color-muted); } &.quarkdown-paged { pre code { padding-left: 0; padding-right: 0; border: none; } } &.quarkdown-slides { blockquote { box-shadow: none; } } .hljs-ln td:first-child { padding-left: 0 !important; } .box.callout { --box-header-background-color: rgba(0, 0, 0, .03); } .mermaid .plot .line-plot-3 path { stroke: lightskyblue; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_alignment.scss ================================================ .quarkdown { text-align: var(--qd-horizontal-alignment-global); // Local alignment (e.g. justification) is applied to specific elements instead of the whole document (#123). p:not(.page-margin-content p, .mermaid p) { text-align: var(--qd-horizontal-alignment-local); text-align-last: var(--qd-horizontal-alignment-global); } li:not(nav li) { text-align: var(--qd-horizontal-alignment-list-items, var(--qd-horizontal-alignment-local)); text-align-last: start; } caption, figcaption { text-align: var(--qd-horizontal-alignment-local); text-align-last: center; } [style*="text-align"] > :is(p, li, span) { text-align: unset !important; text-align-last: unset !important; } // #263 formula { text-align: initial; text-align-last: initial; } } .quarkdown-plain, .quarkdown-paged { --qd-horizontal-alignment-global: start; --qd-horizontal-alignment-local: justify; --qd-horizontal-alignment-list-items: justify; } .quarkdown-slides { --qd-horizontal-alignment-global: center; --qd-horizontal-alignment-local: center; --qd-horizontal-alignment-list-items: start; .reveal .slides > :is(section, .pdf-page) { text-align: var(--qd-horizontal-alignment-global); } .speaker-notes { --qd-horizontal-alignment-global: start; --qd-horizontal-alignment-local: start; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_bibliography.scss ================================================ .quarkdown .bibliography { text-align: start; padding: 0; list-style-type: none; display: grid; grid-template-columns: auto auto; column-gap: 0.5em; row-gap: 1em; .bibliography-entry-content { // #123 break-inside: avoid; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_block.scss ================================================ .quarkdown { // Vertical block spacing. table:not(code table), pre, figure, blockquote, hr, .stack:not(.stack .stack), .file-tree { margin-top: var(--qd-block-margin); margin-bottom: var(--qd-block-margin); } .box { $margin: calc(var(--qd-block-margin) * var(--qd-box-margin-multiplier)); margin-top: $margin; margin-bottom: $margin; } // Resets the top margin of the first element in a page. .paged-content-wrapper > :first-child, .paged-content-wrapper > [data-hidden]:first-child ~ :nth-child(2) { margin-top: 0 !important; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_blockquote.scss ================================================ .quarkdown { blockquote { display: block; color: var(--qd-quote-foreground-color); padding: var(--qd-block-margin); &:not(.quarkdown-slides blockquote) { margin-left: 0; margin-right: 0; } :first-child { margin-top: 0; } :last-child { margin-bottom: 0; } // Attribution .attribution { opacity: 0.6; display: block !important; margin-top: 0 !important; &::before { content: var(--qd-quote-attribution-prefix); } } // Class attribute = quote type (e.g. tip, note, warning, ...) &:not([class]) { background-color: var(--qd-quote-background-color); } &[class] { // This can be overridden by the renderer to display a localized label for each quote type, e.g. 'Tip:' --quote-type-label: ""; font-style: normal; background-color: color-mix(in srgb, var(--box-header-foreground-color) 5%, transparent); border-color: var(--box-header-foreground-color); color: var(--box-content-foreground-color); // The alert label, e.g. 'Tip:', 'Note:', 'Warning:', etc. &[data-labeled] > :first-child::before { content: var(--quote-type-label) var(--qd-quote-type-label-suffix); font-weight: bold; font-style: normal; color: var(--box-header-foreground-color); } } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_box.scss ================================================ @use "_font"; @use "util/icon" as *; .quarkdown .box { --box-background-color: transparent; --box-content-foreground-color: var(--qd-main-color); --box-header-foreground-color: var(--qd-main-color); --box-header-background-color: var(--qd-box-header-background-color, color-mix(in srgb, var(--box-header-foreground-color, var(--qd-main-color)) 2.5%, color-mix(in srgb, var(--box-background-color, transparent) 98%, rgba(0, 0, 0, .1) ) ) ); width: 100%; border-radius: var(--qd-box-border-radius); background-color: var(--box-background-color); break-inside: avoid; > header, > .box-content { padding: var(--qd-box-section-padding); } > header { display: flex; color: var(--box-header-foreground-color); background-color: var(--box-header-background-color); border-radius: var(--qd-box-border-radius) var(--qd-box-border-radius) 0 0; p { margin: 0; } > h4 { color: var(--box-header-foreground-color); margin: 0; @include font.global-font-family-with-heading-fallback($kind: "box-heading"); } } > .box-content { color: var(--box-content-foreground-color); > :first-child { margin-top: var(--qd-box-content-vertical-padding); } > :last-child { margin-bottom: var(--qd-box-content-vertical-padding); } ul, ol { padding-left: 18px; @at-root .quarkdown-paged#{&} { padding-left: 24px; } @at-root .quarkdown-slides#{&} { padding-left: 4px; } } } &.error { > .box-content { font-size: 0.7em; font-family: var(--qd-code-font), monospace; } .inline-collapse[data-collapsed="true"] { --qd-collapsed-text-color: var(--qd-error-message-collapsed-text-color); background-color: rgba(0, 0, 0, 0.09); border: none; } } // Header icons > header > h4::before { font-weight: normal; float: left; margin-right: 10px; margin-top: var(--qd-box-icon-baseline); } @mixin header-icon($icon, $margin-top: null) { > header > h4 { display: flex; flex-direction: row; align-items: center; // Align icons justify-content: var(--qd-horizontal-alignment-global); &::before { @include icon($icon); @if $margin-top != null { margin-top: $margin-top; } font-size: 0.8em; } } } &.tip { @include header-icon("\f46b"); /* 'lightbulb' icon */ } &.note { @include header-icon("\f431"); /* 'info circle' icon */ } &.warning { @include header-icon("\f33b", $margin-top: 0px); /* 'warning triangle' icon */ } } // Box (and quote) types .quarkdown { .callout { --box-background-color: var(--qd-callout-background-color); --box-content-foreground-color: var(--qd-callout-content-foreground-color); --box-header-foreground-color: var(--qd-callout-title-foreground-color); } .tip { --box-background-color: var(--qd-tip-background-color); --box-content-foreground-color: var(--qd-tip-content-foreground-color); --box-header-foreground-color: var(--qd-tip-title-foreground-color); } .note { --box-background-color: var(--qd-note-background-color); --box-content-foreground-color: var(--qd-note-content-foreground-color); --box-header-foreground-color: var(--qd-note-title-foreground-color); } .important { --box-background-color: var(--qd-important-background-color); --box-content-foreground-color: var(--qd-important-content-foreground-color); --box-header-foreground-color: var(--qd-important-title-foreground-color); } .warning { --box-background-color: var(--qd-warning-background-color); --box-content-foreground-color: var(--qd-warning-content-foreground-color); --box-header-foreground-color: var(--qd-warning-title-foreground-color); } .error { --box-header-background-color: rgba(0, 0, 0, .1); --box-background-color: var(--qd-error-background-color); --box-content-foreground-color: var(--qd-error-content-foreground-color); --box-header-foreground-color: var(--qd-error-title-foreground-color); &, p { text-align: start !important; } pre { background-color: var(--box-header-background-color); padding: 12px; code { color: var(--box-header-foreground-color); } } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_clip.scss ================================================ .quarkdown { .clip { --clip-path: none; } .clip > .container:not(:has(> figure)) { clip-path: var(--clip-path); } .clip > .container > figure > :not(figcaption) { clip-path: var(--clip-path); } .clip-circle { --clip-path: circle(); } } ================================================ FILE: quarkdown-html/src/main/scss/components/_code.scss ================================================ @use "font"; .quarkdown { code { @include font.global-font-family("code", monospace); text-align: start; &:not(pre code) { font-size: var(--qd-code-span-font-size); } } pre { min-width: 20%; &:not(.stack > pre, .box pre) { min-width: 100%; } } pre code { font-size: var(--qd-code-block-font-size); line-height: var(--qd-code-line-height); font-style: normal; max-height: none; overflow: hidden; @at-root .quarkdown-slides#{&} { font-size: var(--qd-slides-code-block-font-size); } // Line numbers are displayed in a table, so no borders by default table { td, th { border: 0; } } } pre, pre code { border-radius: var(--qd-code-border-radius); } // Code block line numbers .hljs-ln td { padding: var(--qd-code-line-numbers-padding) !important; } .hljs-ln-numbers { opacity: var(--qd-code-line-numbers-opacity); } // Diff .hljs-deletion { background-color: darkred; color: white; } .hljs-addition { background-color: darkgreen; color: white; } // Code block copy button .hljs-copy-wrapper { .hljs-copy-container { --hljs-theme-padding: 16px !important; transform: none; opacity: 0; transition: opacity 300ms ease; } .hljs-copy-button { cursor: pointer; } &:hover .hljs-copy-container { opacity: .8; } } // Code span + additional content .codespan-content { // Has additional content. &:has(:nth-child(2)) { display: inline-flex; align-items: center; } .color-preview { margin-left: 0.4em; width: 0.5em; height: 0.5em; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_collapsible.scss ================================================ .quarkdown { details summary, .collapsible-text[data-collapsed="true"] { cursor: pointer; } details summary { margin-bottom: var(--qd-paragraph-vertical-margin); } .inline-collapse[data-collapsed="true"] { cursor: pointer; font-weight: bold; color: var(--qd-collapsed-text-color); border-bottom: 3px dotted color-mix(in srgb, var(--qd-collapsed-text-color) 28%, transparent); } } ================================================ FILE: quarkdown-html/src/main/scss/components/_container.scss ================================================ .quarkdown .container { display: inline-block; &.fullwidth, &.full-column-span { display: block; } &:not(.fullwidth):not(.clip > .container) :only-child { margin: 0; } > .stack { width: 100%; height: 100%; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_docs.scss ================================================ @use "util/media-queries" as media-queries; @use "util/heading-selectors" as *; @use "../layout/util/progressive-heading-sizes" as *; @use "../layout/util/progressive-heading-margins" as *; .quarkdown-docs { --qd-nested-navigation-padding-left: 1.2em; > header { background-color: var(--qd-docs-header-background-color); > main { display: grid; } a { color: var(--qd-main-color); transition: color 0.2s; &:hover { color: var(--qd-link-color); } } img { max-height: calc(var(--qd-docs-header-height) - 16px); } .icon-image { font-size: 1.8em; } } > .content-wrapper { > main > footer { margin-top: 32px; display: flex; flex-direction: row; justify-content: space-between; } > aside:first-child { border-right: 1px solid var(--qd-docs-separator-border-color); } > aside:last-child { border-left: 1px solid var(--qd-docs-separator-border-color); } &::after { content: ""; transition: background-color 0.3s, backdrop-filter 0.3s; } } &:has(#search-results:not(:empty):not([hidden])) { > .content-wrapper::after { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); backdrop-filter: blur(1px); pointer-events: none; z-index: 1; } } aside { a { text-decoration: none; &[aria-current] { font-weight: bold; } } figure, img, figure img { margin: 0 !important; } // De-emphasize non-active/non-current links in aside navigation (page list and ToC). nav[data-role="page-list"]:has(a[aria-current]) a:not([aria-current]):not(:hover), nav[data-role="table-of-contents"] li:not(.active) > a:not(:hover) { color: var(--qd-main-color); opacity: 0.8; } nav[data-role="table-of-contents"] a { transition: color 0.2s, opacity 0.2s; } } // Previous/Next page buttons #sibling-pages-button-area { display: flex; gap: 1rem; margin-top: calc(2 * var(--qd-block-margin)); &:empty { display: none; } > a { flex: 1 1 50%; font-size: calc(var(--qd-main-font-size) * 1.1); padding: 1.25rem 1.75rem; border: 1px solid; border-radius: 8px; color: var(--qd-main-color); transition: background-color 0.2s, border-color 0.2s, color 0.2s; text-decoration: none; &:hover { background-color: color-mix(in srgb, var(--qd-link-color) 7%, transparent); border-color: var(--qd-link-color) !important; color: var(--qd-link-color); } .bi { margin: 0 0.8rem; vertical-align: -1px; } &#next-page-anchor { text-align: end; border-color: color-mix(in srgb, var(--qd-main-color) 30%, var(--qd-main-color-muted)); .bi { margin-right: 0; } } &#previous-page-anchor { text-align: start; opacity: 0.7; border-color: var(--qd-main-color-muted); .bi { margin-left: 0; } } } } .page-margin-content { @include progressive-heading-sizes($multiplier: 0.9); @include progressive-heading-margins($bottomAddition: 0); font-size: 0.9em; #{$headings} { font-weight: 500; } } // Avoids flickering before the page margin content is handled at runtime. > .content-wrapper > main .page-margin-content:not(footer .page-margin-content) { display: none; } // Print styles @include media-queries.print { --qd-background-color: white !important; --qd-main-color: black !important; --qd-heading-color: black !important; --qd-link-color: blue !important; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_empty.scss ================================================ $accept-empty: '[data-accept-empty]'; .quarkdown { p:not(#{$accept-empty}):empty { display: none !important; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_figure.scss ================================================ .quarkdown { img { max-width: 100%; max-height: 100%; } figure, .mermaid { text-align: center; } figure { display: flex; flex-direction: column; align-items: center; // Each figure could display its caption on top or bottom > :not(figcaption) { order: 1; } > figcaption { $margin: 0.5em; &.caption-top { order: 0; margin-bottom: $margin; } &.caption-bottom { order: 2; margin-top: $margin; } } &:has(.mermaid, > pre) { margin-left: 0; margin-right: 0; } > pre { margin-top: 0; margin-bottom: 0; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_filetree.scss ================================================ @use "util/icon" as *; .quarkdown .file-tree.file-tree.file-tree { ul { list-style: none; margin: 0.5em 0 0 0.5em; padding: 0 0.75em 0 0.75em; ul { padding-left: 1em; border-left: 1px solid var(--qd-main-color-muted); } li { font-family: var(--qd-code-font), monospace; font-size: var(--qd-code-span-font-size); &::before { margin-right: 0.5em; vertical-align: middle; position: relative; top: -1px; } &[data-highlighted] { width: fit-content; padding: 0.3em; margin: -0.3em; border-radius: 4px; background-color: var(--qd-file-tree-highlight-color); } &.file::before { @include icon("\f392"); // 'file-earmark' icon } &.directory::before { @include icon("\f3d1"); // 'folder-fill' icon } &.ellipsis { color: transparent; &::before { @include icon("\f5d4"); // 'three-dots' icon color: initial; } } } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_float.scss ================================================ $margin: 1.1em; .float[style*="float: inline-start"] { margin-right: $margin; } .float[style*="float: inline-end"] { margin-left: $margin; } ================================================ FILE: quarkdown-html/src/main/scss/components/_focus.scss ================================================ // When an element is marked as focused, other siblings are less visible @mixin unfocused { opacity: 0.4; } .quarkdown { // Unfocused list items :is(ul, ol):has(.focused) { > li:not(.focused, :has(.focused)) { @include unfocused; } } // Unfocused lines of code code.focus-lines { .hljs-ln-line:not(.focused) { @include unfocused; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_font.scss ================================================ // Returns a CSS value for font-family, with fallbacks. Hierarchy of fallbacks: // 1. Custom font (set by user): `--qd-<kind>-custom-font` // 2. Localized font (set by locale): `--qd-<kind>-localized-font` // 3. Base font (set by theme): `--qd-<kind>-font` // 4. Fallback font (set by caller): `$fallback` @function get-global-font-family-value($kind: "main", $fallback: serif) { $custom: --qd-#{$kind}-custom-font; $localized: --qd-#{$kind}-localized-font; $base: --qd-#{$kind}-font; @return var($custom, var($localized, var($base, $fallback) ) ), var($localized, var($base, $fallback)), var($base, $fallback), $fallback; } // Returns a CSS value for font-family, with fallbacks. // Follows the same hierarchy as `get-global-font-family-value`, // but uses the "main" font hierarchy as the final fallback. @function get-global-font-family-value-with-main-fallback($kind) { @return get-global-font-family-value( $kind, get-global-font-family-value("main", serif) ); } @mixin global-font-family($kind: "main", $fallback: serif) { font-family: get-global-font-family-value($kind, $fallback); } @mixin global-font-family-with-main-fallback($kind) { font-family: get-global-font-family-value-with-main-fallback($kind); } @mixin global-font-family-with-heading-fallback($kind) { font-family: get-global-font-family-value( $kind, get-global-font-family-value-with-main-fallback("heading") ); } ================================================ FILE: quarkdown-html/src/main/scss/components/_footnote.scss ================================================ .quarkdown { .footnote-definition { display: flex; flex-direction: row; } .footnote-reference a { text-decoration: none; } .footnote-rule { border-top: 1px solid var(--qd-page-content-border-color); width: 40%; height: 4px; } } .quarkdown-slides .footnote-area { font-size: 0.8em; margin-top: calc(2.5 * var(--qd-block-margin)); } .quarkdown-docs { #footnote-area { font-size: 0.9em; margin-top: calc(2 * var(--qd-block-margin)); } .footnote-rule { width: 20%; } } .quarkdown-plain .footnote-definition { font-size: 0.7em; } ================================================ FILE: quarkdown-html/src/main/scss/components/_heading.scss ================================================ @use "util/heading-selectors" as *; @use "../layout/util/progressive-heading-sizes" as *; @use "../layout/util/progressive-heading-margins" as *; @use "font"; .quarkdown { @include progressive-heading-sizes; @include progressive-heading-margins; #{$headings} { color: var(--qd-heading-color); margin: var(--qd-heading-margin); text-transform: none !important; @include font.global-font-family-with-main-fallback($kind: "heading"); } &.quarkdown-slides section > :is(#{$headings}):first-child { margin-top: 0; } div.marker { display: none; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_hr.scss ================================================ .quarkdown { hr { width: 100%; } // A divider in a row is vertical instead of horizontal .stack-row > hr { display: list-item; width: auto; margin: 0 var(--qd-block-margin); } } ================================================ FILE: quarkdown-html/src/main/scss/components/_landscape.scss ================================================ .quarkdown .landscape { transform: rotate(-90deg) translateX(-100%); transform-origin: top left; } .quarkdown-paged .landscape { transform: rotate(-90deg) translateX(calc(-100% + var(--pagedjs-margin-top) / 2)); width: calc(var(--pagedjs-height-right) - var(--pagedjs-margin-top) - var(--pagedjs-margin-bottom) - 60px) !important; } ================================================ FILE: quarkdown-html/src/main/scss/components/_link.scss ================================================ .quarkdown a { color: var(--qd-link-color); text-decoration: none; } ================================================ FILE: quarkdown-html/src/main/scss/components/_list.scss ================================================ .quarkdown { ul, ol { $margin: calc(var(--qd-paragraph-vertical-margin) * var(--qd-list-margin-multiplier)); margin-top: $margin; margin-bottom: $margin; padding-left: 20px; &:has(> .task-list-item) { padding-left: 0; } } li { // Tight --qd-list-item-margin-multiplier: 0.75; $margin: calc(var(--qd-paragraph-vertical-margin) * var(--qd-list-margin-multiplier) * var(--qd-list-item-margin-multiplier)); margin-top: $margin; margin-bottom: $margin; &:has(p) { // Loose --qd-list-item-margin-multiplier: 1.5; } // If a list item has a custom bullet element, the actual content is wrapped in a container &:has(> div:nth-child(2)) { display: flex; align-items: flex-start; } } // GFM task items .task-list-item { list-style-type: none; input[type="checkbox"]:first-child { margin-right: var(--qd-task-checkbox-margin-right); transform: translateY(1px); } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_location.scss ================================================ @use "util/location-selectors" as selectors; .quarkdown { #{selectors.$location-element} { --qd-label-suffix: var(--qd-caption-label-suffix); // e.g. "Figure 1.1" instead of "Figure 1.1: " if the caption is empty. &:empty { --qd-label-suffix: ""; } &::before { content: attr(data-localized-kind) " " attr(data-location) var(--qd-label-suffix); } } #{selectors.$location-heading} { // Headings and list items use a different suffix. --qd-label-suffix: var(--qd-heading-label-suffix); // Location data in a list item replaces its marker. &:is(li) { list-style-type: none; } } #{selectors.$location-math} { // Math formulas display location differently. position: relative; &::before { content: attr(data-location); position: absolute; inset-inline-end: 0.5rem; top: 50%; transform: translateY(-50%); } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_math.scss ================================================ .quarkdown { .katex.katex { font-size: 1.1em; margin: 0 .1em; } .katex-display { // By swapping margin and padding, // we can ensure Quarkdown containers (e.g. stacks) do not remove the formula's margin. margin: 0; padding: 1em 0; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_mermaid.scss ================================================ .quarkdown { &.quarkdown-plain .mermaid > svg { min-width: 50%; } &.quarkdown-paged { .mermaid > svg { max-height: calc(var(--viewport-remaining-height)); // Fit the diagram into the remaining space of the viewport. } figure:has(figcaption) .mermaid > svg { max-height: calc(var(--viewport-remaining-height) - 100px); } } &.quarkdown-slides .mermaid { box-shadow: none; > svg { max-height: 50vh !important; } } .mermaid { &:not([data-processed]) { font-size: 0; } // Nodes .node path, .actor, rect:not(.legend rect, .plot rect), polygon, circle { &:not(.background) { fill: var(--qd-mermaid-node-background-color) !important; stroke: var(--qd-mermaid-node-border-color) !important; stroke-width: var(--qd-mermaid-node-border-width) !important; } } .node, rect:not(.legend rect, .plot rect), polygon { &:not(.background) { filter: var(--qd-mermaid-node-filter); } } .legend rect { stroke: var(--qd-mermaid-node-border-color) !important; stroke-width: var(--qd-mermaid-node-border-width) !important; } .plot { // Bar chart rect { fill: var(--qd-link-color) !important; opacity: .6; } // Bar chart + line chart &:has(path) rect { opacity: .25; } // Line path { stroke-width: 3px; } .line-plot-0 path { stroke: var(--qd-link-color); } } .background { fill: none !important; } // Text in nodes .nodeLabel { color: var(--qd-mermaid-node-text-color) !important; } // Edge labels .edgeLabel, .edgeLabel p { color: var(--qd-mermaid-node-line-color) !important; background-color: var(--qd-background-color) !important; } // Lines .relation, line, path[class^="edge"], :is(.ticks, .axis-line, .axisl-line) path { stroke: var(--qd-mermaid-node-line-color) !important; } // Text outside nodes .label text, .legend text, .messageText, .loopText, .loopText tspan, .branchLabel text, .title text, text[class*="TitleText"] { fill: var(--qd-mermaid-node-line-color) !important; } .labelBkg { background: none !important; } // Line endings defs, .marker { path { stroke: var(--qd-mermaid-node-line-color) !important; } } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_multicolumn.scss ================================================ .quarkdown.multicolumn { // In a multi-column layout, these elements span across all columns. .full-column-span, h1, h2, h3, nav { column-span: all; } &.quarkdown-plain > main, &.quarkdown-paged .paged-content-wrapper, &.quarkdown-slides .slides section, &.quarkdown-docs > .content-wrapper > main { column-count: var(--qd-column-count); column-fill: auto; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_page-break.scss ================================================ .page-break { break-before: always; } .quarkdown-plain .page-break, .quarkdown-docs .page-break { break-before: avoid; break-after: avoid; } ================================================ FILE: quarkdown-html/src/main/scss/components/_page-margin.scss ================================================ @use "util/media-queries" as media-queries; @use "util/heading-selectors" as *; @use "util/misc-selectors" as *; .quarkdown { // Paged and docs documents have dedicated areas for each margin. Others use fixed positions. .page-margin-content:not(:is(.quarkdown-paged, .quarkdown-docs) .page-margin-content) { position: fixed; background-color: var(--qd-background-color); z-index: 9999; } .page-margin-content:not(.quarkdown-docs .page-margin-content) > #{$margin-reset} { margin: 0 !important; } &.quarkdown-slides .page-margin-content { // 'fixed' is not acceptable for slides PDF (#189). position: absolute !important; } &.quarkdown-docs .page-margin-content { rotate: none !important; transform: none !important; > :is(#{$headings}):first-child, > * > :is(#{$headings}):first-child { margin-top: 0 !important; } } .pagedjs_margin-left-top, .pagedjs_margin-right-top { display: block; } .page-margin-top-left-corner, .page-margin-top-left { top: 0; left: 0; } .page-margin-top-center { top: 0; left: 50%; transform: translateX(-50%); } .page-margin-top-right-corner, .page-margin-top-right { top: 0; right: 0; } .page-margin-right-top { top: 0; right: 0; rotate: 90deg; transform: translateX(50%); } .page-margin-right-middle { top: 50%; right: 0; rotate: 90deg; transform: translateX(-50%); } .page-margin-right-bottom { bottom: 0; right: 0; rotate: 90deg; transform: translateX(-50%); } .page-margin-bottom-right-corner, .page-margin-bottom-right { bottom: 0; right: 0; transform: translateX(-50%); } .page-margin-bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); } .page-margin-bottom-left-corner, .page-margin-bottom-left { bottom: 0; left: 0; transform: translateX(50%); } .page-margin-left-bottom { bottom: 0; left: 0; rotate: -90deg; transform: translateY(-50%); } .page-margin-left-middle { top: 50%; left: 0; rotate: -90deg; transform: translateY(-50%); } .page-margin-left-top { top: 0; left: 0; rotate: -90deg; transform: translateX(-50%); } .pagedjs_page .page-margin-content { // Removes translations from the margin content of paged documents transform: none; } // Footer .page-margin-bottom-center, .page-margin-top-center { display: flex; width: 100%; justify-content: space-around; > * { width: 100%; text-align: center; } } // Experimental fix: Chrome export-to-PDF adds a colored strip to the bottom area. @include media-queries.print { .pagedjs_margin, .pagedjs_margin-bottom, .pagedjs_margin-bottom-left-corner-holder, .pagedjs_margin-bottom-right-corner-holder { background-color: var(--qd-background-color); } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_paged.scss ================================================ :root:has(.quarkdown-paged) { color-scheme: light; } body.quarkdown-paged { background-color: lightgray; // Paged rendering is not yet complete &:not(:has(.pagedjs_page)) { opacity: 0; } .pagedjs_page { box-shadow: 0 16px 48px rgba(0, 0, 0, 0.1); } } ================================================ FILE: quarkdown-html/src/main/scss/components/_paragraph.scss ================================================ .quarkdown.quarkdown { p { margin-top: 0; margin-bottom: 0; text-indent: var(--qd-paragraph-text-indent); } &:not(.quarkdown-slides) p { hyphens: auto; } p, li, td, .footnote-definition { line-height: var(--qd-line-height); letter-spacing: var(--qd-letter-spacing); } } p, .container:has(> p:last-child) { $valid-paragraph-type-1: 'p:not(.stack > p):not(.page-margin-content > p)'; $valid-paragraph-type-2: '.container:has(> p:first-child)'; // These selectors could be shorter, but pagedjs fails on `+` and complex selectors. // Spacing between two sibling paragraphs + #{$valid-paragraph-type-1}, + #{$valid-paragraph-type-2}, // Spacing between two sibling paragraphs with a floating element in-between + .float + #{$valid-paragraph-type-1}, + .float + #{$valid-paragraph-type-2}, // Spacing between the paragraph and its floating element sibling + .float:has(+ #{$valid-paragraph-type-1}) { margin-top: var(--qd-paragraph-vertical-margin) !important; } } // Paragraphs that don't expect indentation. figure p, .stack > p, [style*="text-align"] > p, [style*="justify-items"] > p, .mermaid p, .page-margin-content p { text-indent: 0 !important; } // First paragraphs may not expect indentation, depending on the locale or theme. p:first-child, h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p, hr + p, figure + p, table + p, details > summary + p, .float + p { text-indent: var(--qd-first-paragraph-text-indent, 0) !important; } ================================================ FILE: quarkdown-html/src/main/scss/components/_search.scss ================================================ @use "util/icon" as *; .quarkdown .search-wrapper { display: grid; position: relative; .search-field { display: flex; align-items: center; align-self: center; gap: 8px; max-width: 400px; padding: 8px 12px; border: 1px solid var(--qd-docs-search-border-color); border-radius: 8px; background-color: var(--qd-background-color); transition: border-color 0.2s; &:focus-within { border-color: var(--qd-docs-search-focused-border); i { color: var(--qd-docs-search-focused-border); } } i { margin-left: 2px; opacity: 0.7; } input { flex: 1; border: none; outline: none; background: transparent; font-size: 0.95em; color: var(--qd-main-color); &::placeholder { opacity: 0.7; } } kbd { font-family: inherit; font-size: 0.85em; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--qd-docs-search-border-color); } } #search-results { position: absolute; top: calc(100% - 4px); left: 0; right: 0; max-height: clamp(200px, 50vh, 500px); overflow-y: auto; padding: 6px; background-color: var(--qd-background-color); border: 1px solid var(--qd-docs-separator-border-color); border-radius: 8px; box-shadow: 0 12px 36px rgba(0, 0, 0, 0.1); z-index: 100; .search-result { display: block; padding: 12px 14px; text-decoration: none; color: inherit; transition: background-color 0.15s; &:hover, &.selected { background-color: var(--qd-selected-background-color); } &:first-child { border-top-left-radius: 6px; border-top-right-radius: 6px; } &:last-child { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; } & + .search-result { padding-top: 14px; border-top: 1px solid var(--qd-docs-separator-border-color); } &-title { font-weight: bold; } &-chevron { &::before { @include icon("\f285"); // 'chevron-right' icon font-size: 0.75em; margin: 0 8px; opacity: 0.5; } } &-description { margin-top: 8px; font-size: 0.85em; line-height: 1.5; white-space: pre-line; opacity: 0.7; overflow: hidden; } } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_sidebar.scss ================================================ // Navigation sidebar (not part of the document) @use "util/media-queries" as media-queries; $max-level: 3; $level-width: 9px; .quarkdown nav.sidebar.sidebar.sidebar.sidebar { position: fixed; display: none; top: 50%; right: 0; transform: translateY(-50%); padding: 20px; border-radius: 8px; max-height: 90vh; overflow-y: auto; transition: background-color 0.3s; @include media-queries.sm-up { display: block; } ol { list-style: none; border: none; padding: 0; margin: 0; } li { display: flex; flex-direction: column; align-items: flex-end; margin: 0 !important; @for $level from 1 through $max-level { &[data-depth="#{$level}"] > a::after { width: ($max-level - $level + 1) * $level-width; margin-left: ($level - 1) * $level-width; } } &:before { display: none; } } a { height: 12px; display: flex; align-items: center; cursor: pointer; color: transparent; font-size: 0.75rem; text-decoration: none !important; transition: color 0.2s; padding-top: 2px; padding-bottom: 2px; gap: 12px; &:hover { opacity: 1; &::after { opacity: 1; } } &::after { content: ''; width: 100%; height: 2px; background: var(--qd-main-color); opacity: 0.25; transition: opacity 0.2s; } } // Expanded state. &:hover { background-color: var(--qd-background-color); a { color: var(--qd-main-color); opacity: .85; &:hover { color: var(--qd-link-color); } } } // Collapsed state has fixed color in paged documents. &:not(:hover):is(.quarkdown-paged .sidebar) a::after { background: #444; } li.active { > a::after { opacity: .75; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_size.scss ================================================ $sizes: ( tiny: 0.5em, small: 0.75em, normal: 1em, medium: 1.25em, large: 1.5em, larger: 2em, huge: 3em ); .quarkdown { @each $name, $size in $sizes { .size-#{$name} { font-size: $size; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_smooth-scroll.scss ================================================ // Smooth anchor scrolling @media (prefers-reduced-motion: no-preference) { html:has(.quarkdown-plain, .quarkdown-docs) { scroll-behavior: smooth; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_stack.scss ================================================ @use "util/misc-selectors" as *; .quarkdown { .stack > #{$margin-reset} { margin: 0 !important; } .stack-row { display: flex; flex-direction: row; } .stack-column { display: flex; flex-direction: column; } .stack-grid { display: grid; } .stack-grid > * { margin: 0 !important; } .stack > pre { width: auto; margin: 0; } } ================================================ FILE: quarkdown-html/src/main/scss/components/_table.scss ================================================ .quarkdown table:not(pre table) { th:not([align]) { text-align: var(--qd-table-default-header-alignment); } td:not([align]) { text-align: var(--qd-table-default-cell-alignment); } &:has(> caption.caption-top) { caption-side: top; } &:has(> caption.caption-bottom) { caption-side: bottom; } > caption { white-space: nowrap; overflow-x: hidden; $margin: 1em; &.caption-top { margin-bottom: $margin; } &.caption-bottom { margin-top: $margin; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/_toc.scss ================================================ .quarkdown { h1#table-of-contents { text-align: start; } .current-page-number, .total-page-number { display: inline; } nav { text-align: start; ul, ol { flex-basis: 100%; width: 100%; list-style-position: inside; margin: 0 0 0 1em; &:not(:is(ul, ol) :is(ul, ol)) { padding-left: 0; } } li { display: flex; flex-wrap: wrap; align-items: baseline; a { display: flex; flex-grow: 1; align-items: baseline; .toc-page-number { margin-left: auto; font-variant-numeric: tabular-nums; &:empty, &[data-empty] { display: none; } } } } } } // ToC is scrollable in slides (not in PDF view) .quarkdown-slides nav:not(.pdf-page nav) { overflow-y: scroll; max-height: 50vh; } // Hide ToC title in docs if there are no entries. .quarkdown-docs #table-of-contents:has(+ nav[data-role="table-of-contents"] > :only-child:empty) { visibility: hidden; } ================================================ FILE: quarkdown-html/src/main/scss/components/_viewport.scss ================================================ @use "_font"; @use "util/media-queries" as media-queries; @mixin content-area-border { border-width: var(--qd-page-content-border-width); border-color: var(--qd-page-content-border-color); border-style: var(--qd-page-content-border-style); box-sizing: border-box; } body.quarkdown { color: var(--qd-main-color); font-size: var(--qd-main-font-size); @include font.global-font-family(); &:not(.quarkdown-paged), &.quarkdown-paged .pagedjs_page { background-color: var(--qd-background-color); } // Plain & docs viewport &.quarkdown-plain, &.quarkdown-docs :is(header, .content-wrapper) { display: flex; flex-direction: row; > main { $width: min(100%, var(--qd-content-width)); min-width: $width; max-width: $width; } > aside { --aside-width: 30vw; --aside-outer-margin: 12px; --aside-inner-margin: calc(var(--aside-outer-margin) * 2); width: var(--aside-width); max-width: var(--aside-width); margin: 0 var(--aside-outer-margin); &:first-child { margin-right: var(--aside-inner-margin); } &:last-child { margin-left: var(--aside-inner-margin); } } } // Plain & docs viewport responsive adjustments // Plain reflows at sm breakpoint, docs at md @mixin reflow-plain-like-viewport { flex-direction: column; padding-left: 16px; padding-right: 16px; > main { min-width: 100% !important; max-width: 100% !important; } @include media-queries.sm-down($print: false) { > * { font-size: 1.1em; } } } &.quarkdown-plain { margin: 32px 0; > main { @include content-area-border; } @include media-queries.sm-down($print: true) { @include reflow-plain-like-viewport; > main { border: none; } > aside:first-child { display: none; } // Footnotes go to the bottom > aside:last-child { --aside-inner-margin: 0; max-width: none; width: auto; margin-top: 16px; .footnote-definition { margin-top: 0.5em !important; } } } } // Docs viewport &.quarkdown-docs { --header-height: calc(var(--qd-docs-header-height) + var(--qd-docs-header-vertical-padding) * 2); --content-padding-top: 32px; margin: 0; display: flex; flex-direction: column; > header { height: var(--qd-docs-header-height); padding: var(--qd-docs-header-vertical-padding) 0; position: fixed; top: 0; left: 0; right: 0; z-index: 10; > aside { display: flex; align-items: center; } } > .content-wrapper { margin-top: var(--header-height); > main { margin-bottom: 16px; // Offset anchor scroll position to account for the fixed header. :target { scroll-margin-top: calc(var(--header-height) + var(--content-padding-top)); } } > aside { overflow-y: auto; position: sticky; top: var(--header-height); max-height: calc(100vh - var(--header-height)); padding-top: var(--content-padding-top); } } > :is(header, .content-wrapper) > aside { --aside-width: 20vw; --aside-outer-margin: 32px; padding-left: 20px; &:first-child { margin-left: 0; } } // Higher specificity for responsive adjustments. &.quarkdown-docs { @include media-queries.md-down($print: true) { margin-bottom: 32px; > header, > .content-wrapper { @include reflow-plain-like-viewport; } > header { flex-direction: row; } > .content-wrapper > aside { position: static; width: auto; max-width: none; margin: 16px 0 0 0; padding-left: 0; padding-right: 0; border: none; order: 2; &:first-child { border-top: 1px solid var(--qd-docs-separator-border-color); } } } @include media-queries.md-down($print: false) { > header > aside { display: none; } } @include media-queries.print { > header { .search-wrapper { display: none; } &:has(> aside:first-child:empty):has(> aside:last-child:empty) { display: none; } } } } } // Slides viewport &.quarkdown-slides .reveal { &:not(:has(.pdf-page)) { @include content-area-border; } .slides > .pdf-page { @include content-area-border; } } // Paged viewport &.quarkdown-paged { .pagedjs_pages { display: flex; flex-direction: column; align-items: center; gap: 3mm; } .pagedjs_area { @include content-area-border; } } } ================================================ FILE: quarkdown-html/src/main/scss/components/util/_heading-selectors.scss ================================================ $headings: h1, h2, h3, h4, h5, h6; ================================================ FILE: quarkdown-html/src/main/scss/components/util/_icon.scss ================================================ @mixin icon($unicode) { font-family: "bootstrap-icons", monospace; content: $unicode; } ================================================ FILE: quarkdown-html/src/main/scss/components/util/_location-selectors.scss ================================================ @use "heading-selectors" as *; // Selector for elements that can contain location information. // This may be, for instance, a figure, table, heading. // Location is generically displayed in a caption. // Headings and list items display location as a prefix instead. See $location-heading. $location-element: [data-location]; // Selector for elements that display location as a prefix instead of in a caption, // such as headings and list items. $location-heading: "#{$location-element}:is(#{$headings}, li)"; // Selector for math elements that can contain location information. // The location is displayed differently to other elements. $location-math: "formula#{$location-element}"; ================================================ FILE: quarkdown-html/src/main/scss/components/util/_media-queries.scss ================================================ // Media query utilities and breakpoints. // Mobile $sm: 800px; // Tablet $md: 1200px; @mixin bp-down($breakpoint, $print: false) { @if $print { @media screen and (max-width: $breakpoint), print, pagedjs-ignore { @content; } } @else { @media screen and (max-width: $breakpoint), pagedjs-ignore { @content; } } } @mixin bp-up($breakpoint) { @media screen and (min-width: #{$breakpoint + 1}), pagedjs-ignore { @content; } } @mixin sm-down($print: false) { @include bp-down($sm, $print) { @content; } } @mixin sm-up { @include bp-up($sm) { @content; } } @mixin md-down($print: false) { @include bp-down($md, $print) { @content; } } @mixin md-up { @include bp-up($md) { @content; } } // Targets print media. @mixin print { @media print { @content; } } ================================================ FILE: quarkdown-html/src/main/scss/components/util/_misc-selectors.scss ================================================ $margin-reset: ":is(p, h1, h2, h3, h4, h5, h6, figure)"; ================================================ FILE: quarkdown-html/src/main/scss/global.scss ================================================ @use "components/viewport"; @use "components/alignment"; @use "components/page-break"; @use "components/block"; @use "components/multicolumn"; @use "components/heading"; @use "components/paragraph"; @use "components/empty"; @use "components/link"; @use "components/math"; @use "components/hr"; @use "components/list"; @use "components/table"; @use "components/code"; @use "components/focus"; @use "components/blockquote"; @use "components/figure"; @use "components/mermaid"; @use "components/filetree"; @use "components/float"; @use "components/clip"; @use "components/box"; @use "components/size"; @use "components/collapsible"; @use "components/container"; @use "components/stack"; @use "components/location"; @use "components/footnote"; @use "components/toc"; @use "components/bibliography"; @use "components/page-margin"; @use "components/landscape"; @use "components/paged"; @use "components/docs"; @use "components/smooth-scroll"; @use "components/search.scss"; @use "components/sidebar"; :root { // Quarkdown's exposed properties // Colors --qd-color-scheme: light; // Color scheme: light or dark --qd-background-color: inherit; // Background color --qd-main-color: inherit; // Main text color --qd-main-color-muted: color-mix(in srgb, var(--qd-main-color) 20%, transparent); // Muted main text color --qd-primary-color: var(--qd-main-color); // Primary/accent color --qd-color-on-primary: var(--qd-background-color); // Text color on primary background color --qd-heading-color: var(--qd-main-color); // Heading (titles) color --qd-link-color: inherit; // Link color --qd-nested-navigation-line-color: color-mix(in srgb, var(--qd-primary-color) 20%, transparent); // When expected by the layout theme, sets the color of lines in nested navigation (e.g. ToC) --qd-selected-background-color: color-mix(in srgb, var(--qd-main-color) 5%, transparent); // Background color of selected items over the main background color (e.g. selected search result) --qd-callout-box-header-color: var(--qd-main-color); // Color of the header of callout box headers. This is just a referenceable value and is not used in this global theme --qd-collapsed-text-color: var(--qd-link-color); // Color of expandible text --qd-error-message-collapsed-text-color: rgb(240, 246, 77); // Color of expandible text in error messages // Fonts --qd-main-font: inherit; --qd-heading-font: inherit; --qd-box-heading-font: inherit; --qd-main-font-size: 1em; --qd-code-font: monospace; --qd-code-span-font-size: var(--qd-main-font-size); --qd-code-block-font-size: var(--qd-code-span-font-size); // Font size of code blocks in non-slides documents --qd-slides-code-block-font-size: var(--qd-code-block-font-size); // Font size of code blocks in slides documents // Localized fonts, which override the base fonts for specific locales (e.g. Chinese, see locale/zh.scss). // Avoid overwriting these elsewhere. --qd-main-localized-font: unset; --qd-heading-localized-font: unset; --qd-code-localized-font: unset; // Custom fonts, which override both base and localized fonts. // These are set by the user via the `.font` function. Avoid overwriting these. --qd-main-custom-font: unset; --qd-heading-custom-font: unset; --qd-code-custom-font: unset; // Margins --qd-block-margin: 2em; // Margin of block elements --qd-paragraph-vertical-margin: var(--qd-block-margin); // Vertical margin of paragraphs preceded by a paragraph --qd-heading-margin: 40px 0 20px 0; // Margin of headings --qd-task-checkbox-margin-right: 0.9em; // Right margin of checkboxes in GFM task list items --qd-list-margin-multiplier: 0.4; // --qd-paragraph-vertical-margin * this = list vertical margin --qd-box-margin-multiplier: 1.5; // --qd-block-margin * this = box vertical margin --qd-box-content-vertical-padding: 8px; // Vertical padding of box content --qd-nested-navigation-padding-left: 28px; // When expected by the layout theme, padding-left of nested ToC entries // Border --qd-page-content-border-width: unset; --qd-page-content-border-color: var(--qd-main-color); --qd-page-content-border-style: unset; // Text --qd-line-height: 1.5; --qd-letter-spacing: normal; --qd-paragraph-text-indent: none; // Paragraph indentation (with LaTeX policies) // Misc --qd-content-width: 45rem; // Width of the main content area of plain and docs viewports --qd-column-count: unset; // Number of columns for multi-column layout // Location labels --qd-heading-label-suffix: ". "; // Suffix for heading labels, such as location numbering (e.g. the last `.` in `1.1. Heading`) --qd-caption-label-suffix: ": "; // Suffix for labels in captions (e.g. the `:` in `Figure 1.1: ...`) // Global horizontal alignment of content. // This is applied to all elements in the document. --qd-horizontal-alignment-global: start; // Local horizontal alignment of content. // This is applied only to selected elements in the document (e.g. justification). --qd-horizontal-alignment-local: var(--qd-horizontal-alignment-global); // Horizontal alignment of list items. // Defaults to local alignment, but can be customized separately in case the local alignment is `center`. --qd-horizontal-alignment-list-items: unset; // Code --qd-code-line-height: var(--qd-line-height); // Line height of code blocks --qd-code-border-radius: 8px; // Border radius of code blocks --qd-code-line-numbers-padding: 0 0.8em; // Padding of line numbers of code blocks. Vertical padding also defines code line height. --qd-code-line-numbers-opacity: 0.6; // Opacity of line numbers of code blocks // Tables --qd-table-default-cell-alignment: initial; // Text alignment of table cells, if not specified --qd-table-default-header-alignment: var(--qd-table-default-cell-alignment); // Text alignment of table header cells, if not specified // Quotes --qd-quote-type-label-suffix: ": "; // Suffix for localized quote type labels, e.g. Tip, Note, Warning, ... --qd-quote-attribution-prefix: "— "; // Prefix for quote attributions --qd-quote-foreground-color: var(--qd-main-color); --qd-quote-background-color: color-mix(in srgb, var(--qd-quote-foreground-color) 5%, transparent); // Boxes --qd-box-border-radius: 8px; --qd-box-section-padding: 12px 16px; // Padding of box sections (header and content) --qd-box-icon-baseline: 0; // Y offset of icons in box headers --qd-box-header-background-color: unset; // Background color of box headers (if not set, computed from header foreground color) // Callout elements --qd-callout-content-foreground-color: var(--qd-color-on-primary); --qd-callout-title-foreground-color: var(--qd-color-on-primary); --qd-callout-background-color: color-mix(in srgb, var(--qd-callout-title-foreground-color) 5%, transparent); // Tip elements --qd-tip-content-foreground-color: var(--qd-main-color); --qd-tip-title-foreground-color: rgb(33, 102, 25); --qd-tip-background-color: color-mix(in srgb, var(--qd-tip-title-foreground-color) 5%, transparent); // Note elements --qd-note-content-foreground-color: var(--qd-main-color); --qd-note-title-foreground-color: rgb(25, 60, 102); --qd-note-background-color: color-mix(in srgb, var(--qd-note-title-foreground-color) 5%, transparent); // Warning elements --qd-warning-content-foreground-color: var(--qd-main-color); --qd-warning-title-foreground-color: rgb(161, 76, 32); --qd-warning-background-color: color-mix(in srgb, var(--qd-warning-title-foreground-color) 5%, transparent); // Important elements --qd-important-content-foreground-color: var(--qd-main-color); --qd-important-title-foreground-color: rgb(145, 34, 34); --qd-important-background-color: color-mix(in srgb, var(--qd-important-title-foreground-color) 5%, transparent); // Error elements --qd-error-content-foreground-color: white; --qd-error-title-foreground-color: white; --qd-error-background-color: rgb(224, 67, 64); // Mermaid diagrams --qd-mermaid-node-background-color: var(--qd-primary-color); --qd-mermaid-node-border-color: var(--qd-mermaid-node-text-color); --qd-mermaid-node-border-width: 1px; --qd-mermaid-node-text-color: var(--qd-color-on-primary); --qd-mermaid-node-line-color: var(--qd-main-color); --qd-mermaid-node-filter: none; // File tree --qd-file-tree-highlight-color: color-mix(in srgb, var(--qd-link-color) 10%, transparent); // Docs --qd-docs-header-height: 60px; --qd-docs-header-vertical-padding: 12px; --qd-docs-header-background-color: var(--qd-primary-color); --qd-docs-separator-border-color: var(--qd-docs-header-background-color); --qd-docs-search-border-color: var(--qd-docs-separator-border-color); --qd-docs-search-focused-border: var(--qd-primary-color); // Do not modify the following properties directly. color-scheme: var(--qd-color-scheme); // Reveal properties. --r-background-color: var(--qd-background-color); --r-main-color: var(--qd-main-color); --r-heading-color: var(--qd-heading-color); --r-link-color: var(--qd-link-color); --r-main-font: var(--qd-main-font); --r-main-font-size: var(--qd-main-font-size); --r-code-font: var(--qd-code-font); --r-heading-font: var(--qd-heading-font); --r-block-margin: var(--qd-block-margin); --r-heading-margin: var(--qd-heading-margin); // Injected properties. --viewport-remaining-height: 50vh; } ================================================ FILE: quarkdown-html/src/main/scss/layout/beamer.scss ================================================ /* LaTeX Beamer layout theme */ @use "util/progressive-heading-sizes" as *; @use "util/latex-tables" as *; @use "util/beamer-toc" as *; @import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro'); @import url('https://fonts.googleapis.com/css?family=Fira+Sans'); @import url('https://fonts.googleapis.com/css?family=Noto+Sans+Mono'); :root { --reveal-center-vertically: false; --qd-main-font: "Source Sans Pro", sans-serif; --qd-heading-font: "Fira Sans", sans-serif; --qd-code-font: "Noto Sans Mono", monospace; --qd-code-span-font-size: 0.9em; --qd-code-block-font-size: 0.75em; --qd-slides-code-block-font-size: 1.25em; --qd-block-margin: 32px; --qd-box-margin-multiplier: 1.2; } .quarkdown-slides { --qd-horizontal-alignment-global: start; } .quarkdown { @include progressive-heading-sizes($multiplier: 0.9); @include latex-tables; @include beamer-toc; h1 { padding: 12px; } p { line-height: 1.3; } pre code, ul, ol { line-height: 1.5; } blockquote { font-style: italic; opacity: 0.7; background: none !important; padding: 8px 0; &[class] { font-style: normal; } .attribution { text-align: end; } } .box { border-radius: 8px; > header { border-radius: 8px 8px 0 0; } &:has(> header) > .box-content { margin-top: 0; } } .codespan-content { margin: 0 0.2em; .color-preview { margin: 0 0.2em; border-radius: 100%; border: 0.1em solid; } } // Footer .page-margin-bottom-center > * { padding: 12px; margin-bottom: 0; } } ================================================ FILE: quarkdown-html/src/main/scss/layout/hyperlegible.scss ================================================ @use "util/progressive-heading-sizes" as *; @use "util/progressive-heading-margins" as *; @use "util/gh-tables" as *; @use "util/minimal-toc" as *; @use "util/docs-navigation" as docs-navigation; @use "util/docs-headings-border" as *; @use "../components/util/location-selectors" as location-selectors; :root { --qd-main-font: ui-sans-serif, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", ui-sans-serif, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --qd-code-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --qd-code-span-font-size: 0.9em; --qd-slides-code-block-font-size: 1.3em; --qd-line-height: 1.75; --qd-table-default-header-alignment: center; --qd-heading-label-suffix: ""; --qd-quote-type-label-suffix: ""; --qd-mermaid-node-filter: drop-shadow(1px 2px var(--qd-mermaid-node-border-color)); } .quarkdown { @include progressive-heading-sizes($multiplier: 1.2); @include progressive-heading-margins($multiplier: 1.1, $bottomAddition: 0.4em); @include gh-tables; @include minimal-toc; @include docs-headings-border; h4, h5 { font-weight: 600; } a:hover { text-decoration: underline; } blockquote { border-left: 2px solid; padding-left: 1.5em; &:not([class]) { background-color: transparent !important; border-color: var(--qd-main-color-muted); padding-left: 1em; padding-top: .5em; padding-bottom: .5em; } &[data-labeled] > :first-child::before { display: block; } } pre { --qd-block-margin: 3em; } .box > header > h4 { font-size: 1.2em; } .codespan-content { padding: .12rem .35rem; background-color: color-mix(in srgb, currentColor 3%, transparent); } #{location-selectors.$location-heading}::before { margin-right: .5em; } nav { ul, ol { ul, ol { border-width: 1px; margin-left: .75em; padding-left: .9em; } } } &.quarkdown-docs { header { border-bottom: 1px solid var(--qd-background-color); } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/latex.scss ================================================ /* Inspired by latex-css https://github.com/vincentdoerig/latex-css */ @use "util/progressive-heading-margins" as *; @use "util/progressive-heading-sizes" as *; @use "util/latex-tables" as *; @use "util/latex-toc" as *; @use "../components/util/location-selectors" as location-selctors; @import url('https://cdn.jsdelivr.net/gh/aaaakshat/cm-web-fonts@latest/fonts.css'); @import url('https://cdn.jsdelivr.net/gh/sugina-dev/latin-modern-web@1.0.1/style/latinmodern-mono.css'); :root { --qd-main-font: 'Computer Modern Serif', sans-serif; --qd-main-font-size: 1rem; --qd-heading-margin: 1.4em 0 1em 0; --qd-code-font: "Latin Modern Mono", monospace; --qd-code-span-font-size: 0.93em; --qd-slides-code-block-font-size: 1.5em; --qd-block-margin: 1.8em; --qd-paragraph-vertical-margin: 1.5em; --qd-line-height: 1.5; --qd-heading-label-suffix: " "; --qd-table-default-cell-alignment: start; --qd-quote-type-label-suffix: ". "; --qd-box-icon-baseline: -0.1em; --qd-mermaid-node-filter: drop-shadow(1px 2px var(--qd-mermaid-node-border-color)); } .quarkdown-slides { --qd-horizontal-alignment-global: start; } @page { margin: 0.85in; } .quarkdown { @include progressive-heading-margins; @include progressive-heading-sizes; @include latex-tables; @include latex-toc; // Numbering of headings and list items. #{location-selctors.$location-heading}::before { padding-right: 0.85rem; } h6 { font-size: 1rem; font-style: italic; font-weight: normal; } a { text-decoration: underline; } blockquote { border-left: 2px solid; padding: 14px 1.5em; font-style: italic; .attribution { text-align: end; } // Tip, note, warning, ... &[class] { font-style: normal; } } .box.callout { border-color: var(--box-header-background-color) !important; border-left: 2px solid; border-radius: 8px 8px 8px 0; > .box-content { background-color: var(--qd-background-color); } &:not(:has(> header)) { border-top: 16px solid; } } .codespan-content { border-radius: 8px; background: none !important; .color-preview { margin: 0 0.3em 0 0.4em; border-radius: 3px; } } h1#table-of-contents { text-align: start; } } ================================================ FILE: quarkdown-html/src/main/scss/layout/minimal.scss ================================================ @use "util/progressive-heading-sizes" as *; @use "util/progressive-heading-margins" as *; @use "util/minimal-tables" as *; @use "util/minimal-toc" as *; @use "util/minimal-footer" as *; @use "util/docs-headings-border" as *; @use "util/docs-navigation" as docs-navigation; @use "../components/util/location-selectors" as location-selectors; @import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&display=swap'); @import url('https://fonts.googleapis.com/css?family=Inter'); @import url('https://fonts.googleapis.com/css?family=Noto+Sans+Mono'); :root { --qd-main-font: "Lato", sans-serif; --qd-heading-font: "Inter", sans-serif; --qd-heading-margin: 60px 0 30px 0; --qd-code-font: "Noto Sans Mono", monospace; --qd-slides-code-block-font-size: 1.3em; --qd-code-span-font-size: 0.9em; --qd-block-margin: 1.8em; --qd-line-height: 1.6; --qd-code-line-height: 1.85; --qd-heading-label-suffix: ""; --qd-box-border-radius: 10px; --qd-box-icon-baseline: 0.1em; } .quarkdown-slides { --qd-line-height: 1.8; } .quarkdown { @include progressive-heading-sizes($multiplier: 1.3); @include progressive-heading-margins($bottomAddition: 0.4em); @include minimal-tables; @include minimal-toc; @include minimal-footer; @include docs-headings-border; @include docs-navigation.selected-docs-nav-item-background; ul, ol { line-height: 1.5; } blockquote { font-style: italic; border-left: 0.4em solid; padding-left: 1em; padding-right: 1em; } figure img { margin-bottom: 0.5em !important; } figure figcaption, table caption { font-size: 0.8em; font-style: italic; } .box > header > h4 { font-size: 1em; } .codespan-content { margin: 0 0.3em; .color-preview { border-radius: 3px; border: 0.1em solid color-mix(in srgb, currentColor 60%, transparent); } } .cross-reference { font-style: italic; } #{location-selectors.$location-heading}::before { opacity: .3; font-weight: normal; margin-right: 0.4em; } &.quarkdown-docs { header { border-bottom: 1px solid var(--qd-docs-separator-border-color); } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_beamer-toc.scss ================================================ @use "latex-toc" as *; @mixin beamer-toc { nav { a:not(:hover) { color: var(--qd-main-color); } > ol { // Non-first-level entries use a regular bullet ol { list-style-type: disc !important; padding-left: 1.35em; li::marker { color: var(--qd-primary-color); border: 1px solid var(--qd-color-on-primary); } } > li { padding-bottom: 0.5em; // Custom marker for first-level entries &::before { background-color: var(--qd-primary-color); color: var(--qd-color-on-primary); border-radius: 100%; margin-right: 1em; padding: 2px 10px; display: inline-block; border: 1px solid color-mix(in srgb, var(--qd-color-on-primary) 25%, transparent); } > a { font-weight: bold; } // Second-level entries > ol { padding-left: 36px; // Third-level (and deeper) entries ol { margin-left: 0; } } } } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_docs-headings-border.scss ================================================ @mixin docs-headings-border { &.quarkdown-docs h1 { border-bottom: 1px solid var(--qd-docs-separator-border-color); padding-bottom: 24px; } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_docs-navigation.scss ================================================ // Utility mixin that detects components that handle navigation in docs documents, // and applies a background color to the selected navigation item. // Non-selected items are applied the same padding to avoid layout shift when selecting items. @mixin selected-docs-nav-item-background { $horizontal-padding: 0.4em; &.quarkdown-docs { aside nav[data-role="page-list"] { margin-left: $horizontal-padding; a { padding: 0.2em $horizontal-padding; transform: translateX(-$horizontal-padding); border-radius: 4px; &[aria-current] { background-color: var(--qd-link-color); color: var(--qd-background-color); } } } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_gh-tables.scss ================================================ // GitHub style tables @mixin gh-tables { table:not(pre table) { border-spacing: 0; border-collapse: collapse; width: 100%; max-width: 100%; overflow: auto; th, td { padding: 8px 14px; border: 1px solid var(--qd-main-color-muted) !important; } th { font-weight: 600; } tr { background-color: var(--qd-background-color); &:nth-child(2n) { background-color: color-mix(in srgb, var(--qd-main-color) 2%, var(--qd-background-color)); } } thead tr { background-color: var(--qd-background-color); } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_latex-tables.scss ================================================ @mixin latex-tables { pre table { --border-width-thick: 0; --border-width-thin: 0; } table:not(pre table) { $table-border-color: var(--qd-main-color); $table-border-thin: 1px solid $table-border-color; $table-border-thick: 2px solid $table-border-color; margin-left: auto; margin-right: auto; border-collapse: collapse; border-spacing: 0; width: auto; max-width: 100%; border-top: $table-border-thick; border-bottom: $table-border-thick; tr > th { &[scope='col'] { // Bottom border for column headings. border-bottom: $table-border-thin; } &[scope='row'] { // Right border for row headings. border-right: $table-border-thin; } } th, td { padding: 0.5rem; line-height: 1.1; } > tbody > tr { &:first-child { th, td { border-top: $table-border-thin; } } &:last-child { th, td { border-bottom: $table-border-thin; } } } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_latex-toc.scss ================================================ @mixin latex-toc { nav { a { text-decoration: none; } ul, ol { margin: 0; padding-left: 0; } > ul, > ol { ul, ol { padding-left: 2rem; } > li { margin-top: 20px; > a:is(nav[data-role="table-of-contents"] a) { font-weight: bold; } } } li { margin-top: 7px; list-style-type: none; /* Only numbering-generated bullets are shown */ } &:is(.quarkdown-docs nav) { > ul, > ol { margin-left: 0; ul, ol { padding-left: 1.5rem; } > li { margin-top: 4px; a:is(nav[data-role="table-of-contents"] a) { font-weight: normal; } } } } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_minimal-footer.scss ================================================ @mixin minimal-footer { .page-margin-bottom-center { > * { padding: 12px; margin: 0 !important; border-top: 2px solid; border-color: inherit; &:first-child { margin-left: 64px !important; } &:last-child { margin-right: 64px !important; } } > p { line-height: 1; } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_minimal-tables.scss ================================================ @mixin minimal-tables { table:not(pre table) { border-spacing: 0; border-collapse: collapse; th, td { padding: 0.5em 1em !important; border: 1px solid; } // Only internal table borders tr { &:first-child th { border-top: 0; } &:last-child td { border-bottom: 0; } th:first-child, td:first-child { border-left: 0; } th:last-child, td:last-child { border-right: 0; } } } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_minimal-toc.scss ================================================ @use "../../components/util/location-selectors" as location-selectors; @mixin minimal-toc { nav { ul, ol { list-style-type: none; margin: 0; padding-left: 12px; // Nested entries ul, ol { border-left: 2px solid var(--qd-nested-navigation-line-color); border-radius: 2px; margin-left: 1px; padding: 0 0 0 var(--qd-nested-navigation-padding-left); } } li { margin: 4px 0; } // Location markers #{location-selectors.$location-element}::before { opacity: .5; } } &.quarkdown-slides { nav > :is(ul, ol) { // Resets the no-clipping workaround because this theme's ToC hides list markers. margin-left: auto; } nav, h1#table-of-contents { margin-left: 44px; } } &.quarkdown-docs { // Visual consistency in docs. --qd-nested-navigation-line-color: var(--qd-docs-separator-border-color); } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_progressive-heading-margins.scss ================================================ // Emulates rem sizing, while allowing for em scaling. @function derive-size($rem-size) { @return calc($rem-size / var(--h-font-scale, 1.5)); } @mixin progressive-heading-margins($multiplier: 1, $bottomAddition: 0) { h1 { line-height: derive-size(3.25em * $multiplier); margin-bottom: derive-size(1.625em * $multiplier + $bottomAddition); } h2 { line-height: derive-size(2em * $multiplier); margin-top: derive-size(3em * $multiplier); } h3 { margin-top: derive-size(2.5em * $multiplier); } h4 { margin-top: derive-size(2em * $multiplier); } h5 { margin-top: derive-size(1.8em * $multiplier); } h6 { margin-top: derive-size(2.5rem * $multiplier); } h3, h4, h5, h6 { line-height: derive-size(1.625em * $multiplier); } h1 + h2 { margin-top: derive-size(1.625em * $multiplier); } h2 + h3, h3 + h4, h4 + h5 { margin-top: derive-size(0.8em * $multiplier); } h5 + h6 { margin-top: derive-size(0.8rem * $multiplier); } h2, h3, h4, h5, h6 { margin-bottom: derive-size(0.8em * $multiplier + $bottomAddition); } } ================================================ FILE: quarkdown-html/src/main/scss/layout/util/_progressive-heading-sizes.scss ================================================ @mixin progressive-heading-sizes($multiplier: 1) { h1 { $scale: 2.5 * $multiplier; --h-font-scale: #{$scale}; font-size: #{$scale}em; } h2 { $scale: 1.7 * $multiplier; --h-font-scale: #{$scale}; font-size: #{$scale}em; } h3 { $scale: 1.4 * $multiplier; --h-font-scale: #{$scale}; font-size: #{$scale}em; } h4 { $scale: 1.2 * $multiplier; --h-font-scale: #{$scale}; font-size: #{$scale}em; } h5, h6 { $scale: 1 * $multiplier; --h-font-scale: #{$scale}; font-size: #{$scale}em; } } ================================================ FILE: quarkdown-html/src/main/scss/locale/zh.scss ================================================ :root { --qd-main-localized-font: "Noto Serif SC"; --qd-heading-localized-font: "Noto Serif SC"; --qd-paragraph-text-indent: 2em; --qd-paragraph-vertical-margin: 0; } .quarkdown { p { --qd-first-paragraph-text-indent: var(--qd-paragraph-text-indent); } ul, ol { list-style-position: inside; &:not(li ul, li ol) { padding-left: 0; } } li { &:not(nav li) { text-indent: var(--qd-paragraph-text-indent); > :not(ul, ol):first-child { display: inline; } } > :first-child { text-indent: 0 !important; } &.task-list-item { > div:nth-child(2) > :first-child { text-indent: 0 !important; } > input[type="checkbox"]:first-child:has(+ div) { margin-left: calc(var(--qd-paragraph-text-indent) + 8px); } } } } /* This is the Noto Serif Simplified Chinese font face. Source: https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@200..900&display=swap Latin characters have been removed to reduce conflicts with the default font. */ /* [4] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.4.woff2) format("woff2"); unicode-range: U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311, U+1f315, U+1f319-1f320, U+1f324, U+1f327, U+1f32a, U+1f32c-1f32d, U+1f330-1f357, U+1f359-1f37e; } /* [5] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.5.woff2) format("woff2"); unicode-range: U+fee3, U+fef3, U+ff03-ff04, U+ff07, U+ff0a, U+ff17-ff19, U+ff1c-ff1d, U+ff20-ff3a, U+ff3c, U+ff3e-ff5b, U+ff5d, U+ff61-ff65, U+ff67-ff6a, U+ff6c, U+ff6f-ff78, U+ff7a-ff7d, U+ff80-ff84, U+ff86, U+ff89-ff8e, U+ff92, U+ff97-ff9b, U+ff9d-ff9f, U+ffe0-ffe4, U+ffe6, U+ffe9, U+ffeb, U+ffed, U+fffc, U+1f004, U+1f170-1f171, U+1f192-1f195, U+1f198-1f19a, U+1f1e6-1f1e8; } /* [6] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.6.woff2) format("woff2"); unicode-range: U+f0a7, U+f0b2, U+f0b7, U+f0c9, U+f0d8, U+f0da, U+f0dc-f0dd, U+f0e0, U+f0e6, U+f0eb, U+f0fc, U+f101, U+f104-f105, U+f107, U+f10b, U+f11b, U+f14b, U+f18a, U+f193, U+f1d6-f1d7, U+f244, U+f27a, U+f296, U+f2ae, U+f471, U+f4b3, U+f610-f611, U+f880-f881, U+f8ec, U+f8f5, U+f8ff, U+f901, U+f90a, U+f92c-f92d, U+f934, U+f937, U+f941, U+f965, U+f967, U+f969, U+f96b, U+f96f, U+f974, U+f978-f979, U+f97e, U+f981, U+f98a, U+f98e, U+f997, U+f99c, U+f9b2, U+f9b5, U+f9ba, U+f9be, U+f9ca, U+f9d0-f9d1, U+f9dd, U+f9e0-f9e1, U+f9e4, U+f9f7, U+fa00-fa01, U+fa08, U+fa0a, U+fa11, U+fb01-fb02, U+fdfc, U+fe0e, U+fe30-fe31, U+fe33-fe44, U+fe49-fe52, U+fe54-fe57, U+fe59-fe66, U+fe68-fe6b, U+fe8e, U+fe92-fe93, U+feae, U+feb8, U+fecb-fecc, U+fee0; } /* [21] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.21.woff2) format("woff2"); unicode-range: U+9f83, U+9f85-9f8d, U+9f90-9f91, U+9f94-9f96, U+9f98, U+9f9b-9f9c, U+9f9e, U+9fa0, U+9fa2, U+9ff0-9fff, U+a001, U+a007, U+a025, U+a046-a047, U+a057, U+a072, U+a078-a079, U+a083, U+a085, U+a100, U+a118, U+a132, U+a134, U+a1f4, U+a242, U+a4a6, U+a4aa, U+a4b0-a4b1, U+a4b3, U+a9c1-a9c2, U+ac00-ac01, U+ac04, U+ac08, U+ac10-ac11, U+ac13-ac16, U+ac19, U+ac1c-ac1d, U+ac24, U+ac70-ac71, U+ac74, U+ac77-ac78, U+ac80-ac81, U+ac83, U+ac8c, U+ac90, U+ac9f-aca0, U+aca8-aca9, U+acac, U+acb0, U+acbd, U+acc1, U+acc4, U+ace0-ace1, U+ace4, U+ace8, U+acf3, U+acf5, U+acfc-acfd, U+ad00, U+ad0c, U+ad11, U+ad1c, U+ad34, U+ad50, U+ad64, U+ad6c, U+ad70, U+ad74, U+ad7f, U+ad81, U+ad8c, U+adc0, U+adc8, U+addc, U+ade0, U+adf8-adf9, U+adfc, U+ae00, U+ae08-ae09, U+ae0b, U+ae30, U+ae34, U+ae38, U+ae40, U+ae4a, U+ae4c, U+ae54, U+ae68, U+aebc, U+aed8, U+af2c-af2d; } /* [22] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.22.woff2) format("woff2"); unicode-range: U+9e30-9e33, U+9e35-9e3b, U+9e3e, U+9e40-9e44, U+9e46-9e4e, U+9e51, U+9e53, U+9e55-9e58, U+9e5a-9e5c, U+9e5e-9e63, U+9e66-9e6e, U+9e71, U+9e73, U+9e75, U+9e78-9e79, U+9e7c-9e7e, U+9e82, U+9e86-9e88, U+9e8b-9e8c, U+9e90-9e91, U+9e93, U+9e95, U+9e97, U+9e9d, U+9ea4-9ea5, U+9ea9-9eaa, U+9eb4-9eb5, U+9eb8-9eba, U+9ebc-9ebf, U+9ec3, U+9ec9, U+9ecd, U+9ed0, U+9ed2-9ed3, U+9ed5-9ed6, U+9ed9, U+9edc-9edd, U+9edf-9ee0, U+9ee2, U+9ee5, U+9ee7-9eea, U+9eef, U+9ef1, U+9ef3-9ef4, U+9ef6, U+9ef9, U+9efb-9efc, U+9efe, U+9f0b, U+9f0d, U+9f10, U+9f14, U+9f17, U+9f19, U+9f22, U+9f29, U+9f2c, U+9f2f, U+9f31, U+9f37, U+9f39, U+9f3d-9f3e, U+9f41, U+9f4a-9f4b, U+9f51-9f52, U+9f61-9f63, U+9f66-9f67, U+9f80-9f81; } /* [23] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.23.woff2) format("woff2"); unicode-range: U+9c82-9c83, U+9c85-9c8c, U+9c8e-9c92, U+9c94-9c9b, U+9c9e-9ca3, U+9ca5-9ca7, U+9ca9, U+9cab, U+9cad-9cae, U+9cb1-9cb7, U+9cb9-9cbd, U+9cbf-9cc0, U+9cc3, U+9cc5-9cc7, U+9cc9-9cd1, U+9cd3-9cda, U+9cdc-9cdd, U+9cdf, U+9ce1-9ce3, U+9ce5, U+9ce9, U+9cee-9cef, U+9cf3-9cf4, U+9cf6, U+9cfc-9cfd, U+9d02, U+9d08-9d09, U+9d12, U+9d1b, U+9d1e, U+9d26, U+9d28, U+9d37, U+9d3b, U+9d3f, U+9d51, U+9d59, U+9d5c-9d5d, U+9d5f-9d61, U+9d6c, U+9d70, U+9d72, U+9d7a, U+9d7e, U+9d84, U+9d89, U+9d8f, U+9d92, U+9daf, U+9db4, U+9db8, U+9dbc, U+9dc4, U+9dc7, U+9dc9, U+9dd7, U+9ddf, U+9df2, U+9df9-9dfa, U+9e0a, U+9e11, U+9e1a, U+9e1e, U+9e20, U+9e22, U+9e28-9e2c, U+9e2e-9e2f; } /* [24] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.24.woff2) format("woff2"); unicode-range: U+9a80, U+9a83, U+9a85, U+9a88-9a8a, U+9a8d-9a8e, U+9a90, U+9a92-9a93, U+9a95-9a96, U+9a98-9a99, U+9a9b-9aa2, U+9aa5, U+9aa7, U+9aaf-9ab1, U+9ab5-9ab6, U+9ab9-9aba, U+9ac0-9ac4, U+9ac8, U+9acb-9acc, U+9ace-9acf, U+9ad1-9ad2, U+9ad9, U+9adf, U+9ae1, U+9ae3, U+9aea-9aeb, U+9aed-9aef, U+9af4, U+9af9, U+9afb, U+9b03-9b04, U+9b06, U+9b08, U+9b0d, U+9b0f-9b10, U+9b13, U+9b18, U+9b1a, U+9b1f, U+9b22-9b23, U+9b25, U+9b27-9b28, U+9b2a, U+9b2f, U+9b31-9b32, U+9b3b, U+9b43, U+9b46-9b49, U+9b4d-9b4e, U+9b51, U+9b56, U+9b58, U+9b5a, U+9b5c, U+9b5f, U+9b61-9b62, U+9b6f, U+9b77, U+9b80, U+9b88, U+9b8b, U+9b8e, U+9b91, U+9b9f-9ba0, U+9ba8, U+9baa-9bab, U+9bad-9bae, U+9bb0-9bb1, U+9bb8, U+9bc9-9bca, U+9bd3, U+9bd6, U+9bdb, U+9be8, U+9bf0-9bf1, U+9c02, U+9c10, U+9c15, U+9c24, U+9c2d, U+9c32, U+9c39, U+9c3b, U+9c40, U+9c47-9c49, U+9c53, U+9c57, U+9c64, U+9c72, U+9c77-9c78, U+9c7b, U+9c7f-9c80; } /* [25] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.25.woff2) format("woff2"); unicode-range: U+98dd, U+98e1-98e2, U+98e7-98ea, U+98ec, U+98ee-98ef, U+98f2, U+98f4, U+98fc-98fe, U+9903, U+9905, U+9908, U+990a, U+990c-990d, U+9913-9914, U+9918, U+991a-991b, U+991e, U+9921, U+9928, U+992c, U+992e, U+9935, U+9938-9939, U+993d-993e, U+9945, U+994b-994c, U+9951-9952, U+9954-9955, U+9957, U+995e, U+9963, U+9966-9969, U+996b-996c, U+996f, U+9974-9975, U+9977-9979, U+997d-997e, U+9980-9981, U+9983-9984, U+9987, U+998a-998b, U+998d-9991, U+9993-9995, U+9997-9998, U+99a5, U+99ab, U+99ad-99ae, U+99b1, U+99b3-99b4, U+99bc, U+99bf, U+99c1, U+99c3-99c6, U+99cc, U+99d0, U+99d2, U+99d5, U+99db, U+99dd, U+99e1, U+99ed, U+99f1, U+99ff, U+9a01, U+9a03-9a04, U+9a0e-9a0f, U+9a11-9a13, U+9a19, U+9a1b, U+9a28, U+9a2b, U+9a30, U+9a32, U+9a37, U+9a40, U+9a45, U+9a4a, U+9a4d-9a4e, U+9a52, U+9a55, U+9a57, U+9a5a-9a5b, U+9a5f, U+9a62, U+9a65, U+9a69, U+9a6b, U+9a6e, U+9a75, U+9a77-9a7a, U+9a7d; } /* [26] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.26.woff2) format("woff2"); unicode-range: U+975b-975c, U+9763, U+9765-9766, U+976c-976d, U+9773, U+9776, U+977a, U+977c, U+9784-9785, U+978e-978f, U+9791-9792, U+9794-9795, U+9798, U+979a, U+979e, U+97a3, U+97a5-97a6, U+97a8, U+97ab-97ac, U+97ae-97af, U+97b2, U+97b4, U+97c6, U+97cb-97cc, U+97d3, U+97d8, U+97dc, U+97e1, U+97ea-97eb, U+97ee, U+97fb, U+97fe-97ff, U+9801-9803, U+9805-9806, U+9808, U+980c, U+9810-9814, U+9817-9818, U+981e, U+9820-9821, U+9824, U+9828, U+982b-982d, U+9830, U+9834, U+9838-9839, U+983c, U+9846, U+984d-984f, U+9851-9852, U+9854-9855, U+9857-9858, U+985a-985b, U+9862-9863, U+9865, U+9867, U+986b, U+986f-9871, U+9877-9878, U+987c, U+9880, U+9883, U+9885, U+9889, U+988b-988f, U+9893-9895, U+9899-989b, U+989e-989f, U+98a1-98a2, U+98a5-98a7, U+98a9, U+98af, U+98b1, U+98b6, U+98ba, U+98be, U+98c3-98c4, U+98c6-98c8, U+98cf-98d6, U+98da-98db; } /* [27] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.27.woff2) format("woff2"); unicode-range: U+95c4-95ca, U+95cc-95cd, U+95d4-95d6, U+95d8, U+95e1-95e2, U+95e9, U+95f0-95f1, U+95f3, U+95f6, U+95fc, U+95fe-95ff, U+9602-9604, U+9606-960d, U+960f, U+9611-9613, U+9615-9617, U+9619-961b, U+961d, U+9621, U+9628, U+962f, U+963c-963e, U+9641-9642, U+9649, U+9654, U+965b-965f, U+9661, U+9663, U+9665, U+9667-9668, U+966c, U+9670, U+9672-9674, U+9678, U+967a, U+967d, U+9682, U+9685, U+9688, U+968a, U+968d-968e, U+9695, U+9697-9698, U+969e, U+96a0, U+96a3-96a4, U+96a8, U+96aa, U+96b0-96b1, U+96b3-96b4, U+96b7-96b9, U+96bb-96bd, U+96c9, U+96cb, U+96ce, U+96d1-96d2, U+96d6, U+96d9, U+96db-96dc, U+96de, U+96e0, U+96e3, U+96e9, U+96eb, U+96f0-96f2, U+96f9, U+96ff, U+9701-9702, U+9705, U+9708, U+970a, U+970e-970f, U+9711, U+9719, U+9727, U+972a, U+972d, U+9730, U+973d, U+9742, U+9744, U+9748-9749, U+9750-9751, U+975a; } /* [28] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.28.woff2) format("woff2"); unicode-range: U+94f5, U+94f7, U+94f9, U+94fb-94fd, U+94ff, U+9503-9504, U+9506-9507, U+9509-950a, U+950d-950f, U+9511-9518, U+951a-9520, U+9522, U+9528-952d, U+9530-953a, U+953c-953f, U+9543-9546, U+9548-9550, U+9552-9555, U+9557-955b, U+955d-9568, U+956a-956d, U+9570-9574, U+9583, U+9586, U+9589, U+958e-958f, U+9591-9592, U+9594, U+9598-9599, U+959e-95a0, U+95a2-95a6, U+95a8-95b2, U+95b4, U+95b8-95c3; } /* [29] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.29.woff2) format("woff2"); unicode-range: U+941c-942b, U+942d-942e, U+9432-9433, U+9435, U+9438, U+943a, U+943e, U+9444, U+944a, U+9451-9452, U+945a, U+9462-9463, U+9465, U+9470-9487, U+948a-9492, U+9494-9498, U+949a, U+949c-949d, U+94a1, U+94a3-94a4, U+94a8, U+94aa-94ad, U+94af, U+94b2, U+94b4-94ba, U+94bc-94c0, U+94c4, U+94c6-94db, U+94de-94ec, U+94ee-94f1, U+94f3; } /* [30] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.30.woff2) format("woff2"); unicode-range: U+92ec-92ed, U+92f0, U+92f3, U+92f8, U+92fc, U+9304, U+9306, U+9310, U+9312, U+9315, U+9318, U+931a, U+931e, U+9320-9322, U+9324, U+9326-9329, U+932b-932c, U+932f, U+9331-9332, U+9335-9336, U+933e, U+9340-9341, U+934a-9360, U+9362-9363, U+9365-936b, U+936e, U+9375, U+937e, U+9382, U+938a, U+938c, U+938f, U+9393-9394, U+9396-9397, U+939a, U+93a2, U+93a7, U+93ac-93cd, U+93d0-93d1, U+93d6-93d8, U+93de-93df, U+93e1-93e2, U+93e4, U+93f8, U+93fb, U+93fd, U+940e-941a; } /* [31] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.31.woff2) format("woff2"); unicode-range: U+9163-9164, U+9169, U+9170, U+9172, U+9174, U+9179-917a, U+917d-917e, U+9182-9183, U+9185, U+918c-918d, U+9190-9191, U+919a, U+919c, U+91a1-91a4, U+91a8, U+91aa-91af, U+91b4-91b5, U+91b8, U+91ba, U+91be, U+91c0-91c1, U+91c6, U+91c8, U+91cb, U+91d0, U+91d2, U+91d7-91d8, U+91dd, U+91e3, U+91e6-91e7, U+91ed, U+91f0, U+91f5, U+91f9, U+9200, U+9205, U+9207-920a, U+920d-920e, U+9210, U+9214-9215, U+921c, U+921e, U+9221, U+9223-9227, U+9229-922a, U+922d, U+9234-9235, U+9237, U+9239-923a, U+923c-9240, U+9244-9246, U+9249, U+924e-924f, U+9251, U+9253, U+9257, U+925b, U+925e, U+9262, U+9264-9266, U+9268, U+926c, U+926f, U+9271, U+927b, U+927e, U+9280, U+9283, U+9285-928a, U+928e, U+9291, U+9293, U+9296, U+9298, U+929c-929d, U+92a8, U+92ab-92ae, U+92b3, U+92b6-92b7, U+92b9, U+92c1, U+92c5-92c6, U+92c8, U+92cc, U+92d0, U+92d2, U+92e4, U+92ea; } /* [32] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.32.woff2) format("woff2"); unicode-range: U+9004, U+900b, U+9011, U+9015-9016, U+901e, U+9021, U+9026, U+902d, U+902f, U+9031, U+9035-9036, U+9039-903a, U+9041, U+9044-9046, U+904a, U+904f-9052, U+9054-9055, U+9058-9059, U+905b-905e, U+9060-9062, U+9068-9069, U+906f, U+9072, U+9074, U+9076-907a, U+907c-907d, U+9081, U+9083, U+9085, U+9087-908b, U+908f, U+9095, U+9097, U+9099-909b, U+909d, U+90a0-90a1, U+90a8-90a9, U+90ac, U+90b0, U+90b2-90b4, U+90b6, U+90b8, U+90ba, U+90bd-90be, U+90c3-90c5, U+90c7-90c8, U+90cf-90d0, U+90d3, U+90d5, U+90d7, U+90da-90dc, U+90de, U+90e2, U+90e4, U+90e6-90e7, U+90ea-90eb, U+90ef, U+90f4-90f5, U+90f7, U+90fe-9100, U+9104, U+9109, U+910c, U+9112, U+9114-9115, U+9118, U+911c, U+911e, U+9120, U+9122-9123, U+9127, U+912d, U+912f-9132, U+9139-913a, U+9143, U+9146, U+9149-914a, U+914c, U+914e-9150, U+9154, U+9157, U+915a, U+915d-915e, U+9161-9162; } /* [33] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.33.woff2) format("woff2"); unicode-range: U+8e41-8e42, U+8e47, U+8e49-8e4b, U+8e50-8e53, U+8e59-8e5a, U+8e5f-8e60, U+8e64, U+8e69, U+8e6c, U+8e70, U+8e74, U+8e76, U+8e7a-8e7c, U+8e7f, U+8e84-8e85, U+8e87, U+8e89, U+8e8b, U+8e8d, U+8e8f-8e90, U+8e94, U+8e99, U+8e9c, U+8e9e, U+8eaa, U+8eac, U+8eb0, U+8eb6, U+8ec0, U+8ec6, U+8eca-8ece, U+8ed2, U+8eda, U+8edf, U+8ee2, U+8eeb, U+8ef8, U+8efb-8efe, U+8f03, U+8f09, U+8f0b, U+8f12-8f15, U+8f1b, U+8f1d, U+8f1f, U+8f29-8f2a, U+8f2f, U+8f36, U+8f38, U+8f3b, U+8f3e-8f3f, U+8f44-8f45, U+8f49, U+8f4d-8f4e, U+8f5f, U+8f6b, U+8f6d, U+8f71-8f73, U+8f75-8f76, U+8f78-8f7a, U+8f7c, U+8f7e, U+8f81-8f82, U+8f84, U+8f87, U+8f8a-8f8b, U+8f8d-8f8f, U+8f94-8f95, U+8f97-8f9a, U+8fa6, U+8fad-8faf, U+8fb2, U+8fb5-8fb7, U+8fba-8fbc, U+8fbf, U+8fc2, U+8fcb, U+8fcd, U+8fd3, U+8fd5, U+8fd7, U+8fda, U+8fe2-8fe5, U+8fe8-8fe9, U+8fee, U+8ff3-8ff4, U+8ff8, U+8ffa; } /* [34] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.34.woff2) format("woff2"); unicode-range: U+8cbd, U+8cbf-8cc4, U+8cc7-8cc8, U+8cca, U+8ccd, U+8cd1, U+8cd3, U+8cdb-8cdc, U+8cde, U+8ce0, U+8ce2-8ce4, U+8ce6-8ce8, U+8cea, U+8ced, U+8cf4, U+8cf8, U+8cfa, U+8cfc-8cfd, U+8d04-8d05, U+8d07-8d08, U+8d0a, U+8d0d, U+8d0f, U+8d13-8d14, U+8d16, U+8d1b, U+8d20, U+8d30, U+8d32-8d33, U+8d36, U+8d3b, U+8d3d, U+8d40, U+8d42-8d43, U+8d45-8d46, U+8d48-8d4a, U+8d4d, U+8d51, U+8d53, U+8d55, U+8d59, U+8d5c-8d5d, U+8d5f, U+8d61, U+8d66-8d67, U+8d6a, U+8d6d, U+8d71, U+8d73, U+8d84, U+8d90-8d91, U+8d94-8d95, U+8d99, U+8da8, U+8daf, U+8db1, U+8db5, U+8db8, U+8dba, U+8dbc, U+8dbf, U+8dc2, U+8dc4, U+8dc6, U+8dcb, U+8dce-8dcf, U+8dd6-8dd7, U+8dda-8ddb, U+8dde, U+8de1, U+8de3-8de4, U+8de9, U+8deb-8dec, U+8df0-8df1, U+8df6-8dfd, U+8e05, U+8e07, U+8e09-8e0a, U+8e0c, U+8e0e, U+8e10, U+8e14, U+8e1d-8e1f, U+8e23, U+8e26, U+8e2b-8e31, U+8e34-8e35, U+8e39-8e3a, U+8e3d, U+8e40; } /* [35] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.35.woff2) format("woff2"); unicode-range: U+8b80, U+8b83, U+8b8a, U+8b8c, U+8b90, U+8b93, U+8b99-8b9a, U+8ba0, U+8ba3, U+8ba5-8ba7, U+8baa-8bac, U+8bb4-8bb5, U+8bb7, U+8bb9, U+8bc2-8bc3, U+8bc5, U+8bcb-8bcc, U+8bce-8bd0, U+8bd2-8bd4, U+8bd6, U+8bd8-8bd9, U+8bdc, U+8bdf, U+8be3-8be4, U+8be7-8be9, U+8beb-8bec, U+8bee, U+8bf0, U+8bf2-8bf3, U+8bf6, U+8bf9, U+8bfc-8bfd, U+8bff-8c00, U+8c02, U+8c04, U+8c06-8c07, U+8c0c, U+8c0f, U+8c11-8c12, U+8c14-8c1b, U+8c1d-8c21, U+8c24-8c25, U+8c27, U+8c2a-8c2c, U+8c2e-8c30, U+8c32-8c36, U+8c3f, U+8c47-8c4c, U+8c4e-8c50, U+8c54-8c56, U+8c62, U+8c68, U+8c6c, U+8c73, U+8c78, U+8c7a, U+8c82, U+8c85, U+8c89-8c8a, U+8c8d-8c8e, U+8c90, U+8c93-8c94, U+8c98, U+8c9d-8c9e, U+8ca0-8ca2, U+8ca7-8cac, U+8caf-8cb0, U+8cb3-8cb4, U+8cb6-8cb9, U+8cbb-8cbc; } /* [36] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.36.woff2) format("woff2"); unicode-range: U+8a15-8a18, U+8a1a-8a1b, U+8a1d, U+8a1f, U+8a22-8a23, U+8a25, U+8a2b, U+8a2d, U+8a31, U+8a33-8a34, U+8a36-8a38, U+8a3a, U+8a3c, U+8a3e, U+8a40-8a41, U+8a46, U+8a48, U+8a50, U+8a52, U+8a54-8a55, U+8a58, U+8a5b, U+8a5d-8a63, U+8a66, U+8a69-8a6b, U+8a6d-8a6e, U+8a70, U+8a72-8a73, U+8a7a, U+8a85, U+8a87, U+8a8a, U+8a8c-8a8d, U+8a90-8a92, U+8a95, U+8a98, U+8aa0-8aa1, U+8aa3-8aa6, U+8aa8-8aa9, U+8aac-8aae, U+8ab0, U+8ab2, U+8ab8-8ab9, U+8abc, U+8abe-8abf, U+8ac7, U+8acf, U+8ad2, U+8ad6-8ad7, U+8adb-8adc, U+8adf, U+8ae1, U+8ae6-8ae8, U+8aeb, U+8aed-8aee, U+8af1, U+8af3-8af4, U+8af7-8af8, U+8afa, U+8afe, U+8b00-8b02, U+8b07, U+8b0a, U+8b0c, U+8b0e, U+8b10, U+8b17, U+8b19, U+8b1b, U+8b1d, U+8b20-8b21, U+8b26, U+8b28, U+8b2c, U+8b33, U+8b39, U+8b3e-8b3f, U+8b41, U+8b45, U+8b49, U+8b4c, U+8b4f, U+8b57-8b58, U+8b5a, U+8b5c, U+8b5e, U+8b60, U+8b6c, U+8b6f-8b70, U+8b72, U+8b74, U+8b77, U+8b7d; } /* [37] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.37.woff2) format("woff2"); unicode-range: U+8882, U+8884-8886, U+8888, U+888f, U+8892-8893, U+889b, U+88a2, U+88a4, U+88a6, U+88a8, U+88aa, U+88ae, U+88b1, U+88b4, U+88b7, U+88bc, U+88c0, U+88c6-88c9, U+88ce-88cf, U+88d1-88d3, U+88d8, U+88db-88dd, U+88df, U+88e1-88e3, U+88e5, U+88e8, U+88ec, U+88f0-88f1, U+88f3-88f4, U+88fc-88fe, U+8900, U+8902, U+8906-8907, U+8909-890c, U+8912-8915, U+8918-891b, U+8921, U+8925, U+892b, U+8930, U+8932, U+8934, U+8936, U+893b, U+893d, U+8941, U+894c, U+8955-8956, U+8959, U+895c, U+895e-8960, U+8966, U+896a, U+896c, U+896f-8970, U+8972, U+897b, U+897e, U+8980, U+8983, U+8985, U+8987-8988, U+898c, U+898f, U+8993, U+8997, U+899a, U+89a1, U+89a7, U+89a9-89aa, U+89b2-89b3, U+89b7, U+89c0, U+89c7, U+89ca-89cc, U+89ce-89d1, U+89d6, U+89da, U+89dc, U+89de, U+89e5, U+89e7, U+89eb, U+89ef, U+89f1, U+89f3-89f4, U+89f8, U+89ff, U+8a01-8a03, U+8a07-8a0a, U+8a0e-8a0f, U+8a13; } /* [38] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.38.woff2) format("woff2"); unicode-range: U+86f4, U+86f8-86f9, U+86fb, U+86fe, U+8703, U+8706-870a, U+870d, U+8711-8713, U+871a, U+871e, U+8722-8723, U+8725, U+8729, U+872e, U+8731, U+8734, U+8737, U+873a-873b, U+873e-8740, U+8742, U+8747-8748, U+8753, U+8755, U+8757-8758, U+875d, U+875f, U+8762-8766, U+8768, U+876e, U+8770, U+8772, U+8775, U+8778, U+877b-877e, U+8782, U+8785, U+8788, U+878b, U+8793, U+8797, U+879a, U+879e-87a0, U+87a2-87a3, U+87a8, U+87ab-87ad, U+87af, U+87b3, U+87b5, U+87bd, U+87c0, U+87c4, U+87c6, U+87ca-87cb, U+87d1-87d2, U+87db-87dc, U+87de, U+87e0, U+87e5, U+87ea, U+87ec, U+87ee, U+87f2-87f3, U+87fb, U+87fd-87fe, U+8802-8803, U+8805, U+880a-880b, U+880d, U+8813-8816, U+8819, U+881b, U+881f, U+8821, U+8823, U+8831-8832, U+8835-8836, U+8839, U+883b-883c, U+8844, U+8846, U+884a, U+884e, U+8852-8853, U+8855, U+8859, U+885b, U+885d-885e, U+8862, U+8864, U+8869-886a, U+886e-886f, U+8872, U+8879, U+887d-887f; } /* [39] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.39.woff2) format("woff2"); unicode-range: U+8548, U+854e, U+8553, U+8556-8557, U+8559, U+855e, U+8561, U+8564-8565, U+8568-856a, U+856d, U+856f-8570, U+8572, U+8576, U+8579-857b, U+8580, U+8585-8586, U+8588, U+858a, U+858f, U+8591, U+8594, U+8599, U+859c, U+85a2, U+85a4, U+85a6, U+85a8-85a9, U+85ab-85ac, U+85ae, U+85b7-85b9, U+85be, U+85c1, U+85c7, U+85cd, U+85d0, U+85d3, U+85d5, U+85dc-85dd, U+85df-85e0, U+85e5-85e6, U+85e8-85ea, U+85f4, U+85f9, U+85fe-85ff, U+8602, U+8605-8607, U+860a-860b, U+8616, U+8618, U+861a, U+8627, U+8629, U+862d, U+8638, U+863c, U+863f, U+864d, U+864f, U+8652-8655, U+865b-865c, U+865f, U+8662, U+8667, U+866c, U+866e, U+8671, U+8675, U+867a-867c, U+867f, U+868b, U+868d, U+8693, U+869c-869d, U+86a1, U+86a3-86a4, U+86a7-86a9, U+86ac, U+86af-86b1, U+86b4-86b6, U+86ba, U+86c0, U+86c4, U+86c6, U+86c9-86ca, U+86cd-86d1, U+86d4, U+86d8, U+86de-86df, U+86e4, U+86e6, U+86e9, U+86ed, U+86ef-86f3; } /* [40] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.40.woff2) format("woff2"); unicode-range: U+83c5, U+83c8-83c9, U+83cb, U+83d1, U+83d3-83d6, U+83d8, U+83db, U+83dd, U+83df, U+83e1, U+83e5, U+83ea-83eb, U+83f0, U+83f4, U+83f8-83f9, U+83fb, U+83fd, U+83ff, U+8401, U+8406, U+840a-840b, U+840f, U+8411, U+8418, U+841c, U+8420, U+8422-8424, U+8426, U+8429, U+842c, U+8438-8439, U+843b-843c, U+843f, U+8446-8447, U+8449, U+844e, U+8451-8452, U+8456, U+8459-845a, U+845c, U+8462, U+8466, U+846d, U+846f-8470, U+8473, U+8476-8478, U+847a, U+847d, U+8484-8485, U+8487, U+8489, U+848c, U+848e, U+8490, U+8493-8494, U+8497, U+849b, U+849e-849f, U+84a1, U+84a5, U+84a8, U+84af, U+84b4, U+84b9-84bf, U+84c1-84c2, U+84c5-84c7, U+84ca-84cb, U+84cd, U+84d0-84d1, U+84d3, U+84d6, U+84df-84e0, U+84e2-84e3, U+84e5-84e7, U+84ee, U+84f3, U+84f6, U+84fa, U+84fc, U+84ff-8500, U+850c, U+8511, U+8514-8515, U+8517-8518, U+851f, U+8523, U+8525-8526, U+8529, U+852b, U+852d, U+8532, U+8534-8535, U+8538-853a, U+853c, U+8543, U+8545; } /* [41] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.41.woff2) format("woff2"); unicode-range: U+82bc, U+82be, U+82c0-82c2, U+82c4-82c8, U+82ca-82cc, U+82ce, U+82d0, U+82d2-82d3, U+82d5-82d6, U+82d8-82d9, U+82dc-82de, U+82e0-82e4, U+82e7, U+82e9-82eb, U+82ed-82ee, U+82f3-82f4, U+82f7-82f8, U+82fa-8301, U+8306-8308, U+830c-830d, U+830f, U+8311, U+8313-8315, U+8318, U+831a-831b, U+831d, U+8324, U+8327, U+832a, U+832c-832d, U+832f, U+8331-8334, U+833a-833c, U+8340, U+8343-8345, U+8347-8348, U+834a, U+834c, U+834f, U+8351, U+8356, U+8358-835c, U+835e, U+8360, U+8364-8366, U+8368-836a, U+836c-836e, U+8373, U+8378, U+837b-837d, U+837f-8380, U+8382, U+8388, U+838a-838b, U+8392, U+8394, U+8396, U+8398-8399, U+839b-839c, U+83a0, U+83a2-83a3, U+83a8-83aa, U+83ae-83b0, U+83b3-83b4, U+83b6, U+83b8, U+83ba, U+83bc-83bd, U+83bf-83c0, U+83c2; } /* [42] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.42.woff2) format("woff2"); unicode-range: U+8166-8169, U+816b, U+816d, U+8171, U+8173-8174, U+8178, U+817c-817d, U+8182, U+8188, U+8191, U+8198-819b, U+81a0, U+81a3, U+81a5-81a6, U+81a9, U+81b6, U+81ba-81bb, U+81bd, U+81bf, U+81c1, U+81c3, U+81c6, U+81c9-81ca, U+81cc-81cd, U+81d1, U+81d3-81d4, U+81d8, U+81db-81dc, U+81de-81df, U+81e5, U+81e7-81e9, U+81eb-81ec, U+81ee-81ef, U+81f5, U+81f8, U+81fa, U+81fc, U+81fe, U+8200-8202, U+8204, U+8208-820a, U+820e-8210, U+8216-8218, U+821b-821c, U+8221-8224, U+8226-8228, U+822b, U+822d, U+822f, U+8232-8234, U+8237-8238, U+823a-823b, U+823e, U+8244, U+8249, U+824b, U+824f, U+8259-825a, U+825f, U+8266, U+8268, U+826e, U+8271, U+8276-8279, U+827d, U+827f, U+8283-8284, U+8288-828a, U+828d-8291, U+8293-8294, U+8296-8298, U+829f-82a1, U+82a3-82a4, U+82a7-82ab, U+82ae, U+82b0, U+82b2, U+82b4-82b6; } /* [43] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.43.woff2) format("woff2"); unicode-range: U+8016, U+8018-8019, U+801c, U+801e, U+8026-802a, U+8031, U+8034-8035, U+8037, U+8043, U+804b, U+804d, U+8052, U+8056, U+8059, U+805e, U+8061, U+8068-8069, U+806e-8074, U+8076-8078, U+807c-8080, U+8082, U+8084-8085, U+8088, U+808f, U+8093, U+809c, U+809f, U+80ab, U+80ad-80ae, U+80b1, U+80b6-80b8, U+80bc-80bd, U+80c2, U+80c4, U+80ca, U+80cd, U+80d1, U+80d4, U+80d7, U+80d9-80db, U+80dd, U+80e0, U+80e4-80e5, U+80e7-80ed, U+80ef-80f1, U+80f3-80f4, U+80fc, U+8101, U+8104-8105, U+8107-8108, U+810c-810e, U+8112-8115, U+8117-8119, U+811b-811f, U+8121-8130, U+8132-8134, U+8137, U+8139, U+813f-8140, U+8142, U+8146, U+8148, U+814d-814e, U+8151, U+8153, U+8158-815a, U+815e, U+8160; } /* [44] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.44.woff2) format("woff2"); unicode-range: U+7ef0-7ef2, U+7ef6, U+7efa-7efb, U+7efe, U+7f01-7f04, U+7f08, U+7f0a-7f12, U+7f17, U+7f19, U+7f1b-7f1c, U+7f1f, U+7f21-7f23, U+7f25-7f28, U+7f2a-7f33, U+7f35-7f37, U+7f3d, U+7f42, U+7f44-7f45, U+7f4c-7f4d, U+7f52, U+7f54, U+7f58-7f59, U+7f5d, U+7f5f-7f61, U+7f63, U+7f65, U+7f68, U+7f70-7f71, U+7f73-7f75, U+7f77, U+7f79, U+7f7d-7f7e, U+7f85-7f86, U+7f88-7f89, U+7f8b-7f8c, U+7f90-7f91, U+7f94-7f96, U+7f98-7f9b, U+7f9d, U+7f9f, U+7fa3, U+7fa7-7fa9, U+7fac-7fb2, U+7fb4, U+7fb6, U+7fb8, U+7fbc, U+7fbf-7fc0, U+7fc3, U+7fca, U+7fcc, U+7fce, U+7fd2, U+7fd5, U+7fd9-7fdb, U+7fdf, U+7fe3, U+7fe5-7fe7, U+7fe9, U+7feb-7fec, U+7fee-7fef, U+7ff1, U+7ff3-7ff4, U+7ff9-7ffa, U+7ffe, U+8004, U+8006, U+800b, U+800e, U+8011-8012, U+8014; } /* [45] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.45.woff2) format("woff2"); unicode-range: U+7dd2, U+7dd4, U+7dd6-7dd8, U+7dda-7de0, U+7de2-7de6, U+7de8-7ded, U+7def, U+7df1-7df5, U+7df7, U+7df9, U+7dfb-7dfc, U+7dfe-7e02, U+7e04, U+7e08-7e0b, U+7e12, U+7e1b, U+7e1e, U+7e20, U+7e22-7e23, U+7e26, U+7e29, U+7e2b, U+7e2e-7e2f, U+7e31, U+7e37, U+7e39-7e3e, U+7e40, U+7e43-7e44, U+7e46-7e47, U+7e4a-7e4b, U+7e4d-7e4e, U+7e51, U+7e54-7e56, U+7e58-7e5b, U+7e5d-7e5e, U+7e61, U+7e66-7e67, U+7e69-7e6b, U+7e6d, U+7e70, U+7e73, U+7e77, U+7e79, U+7e7b-7e7d, U+7e81-7e82, U+7e8c-7e8d, U+7e8f, U+7e92-7e94, U+7e96, U+7e98, U+7e9a-7e9c, U+7e9e-7e9f, U+7ea1, U+7ea3, U+7ea5, U+7ea8-7ea9, U+7eab, U+7ead-7eae, U+7eb0, U+7ebb, U+7ebe, U+7ec0-7ec2, U+7ec9, U+7ecb-7ecc, U+7ed0, U+7ed4, U+7ed7, U+7edb, U+7ee0-7ee2, U+7ee5-7ee6, U+7ee8, U+7eeb; } /* [46] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.46.woff2) format("woff2"); unicode-range: U+7ce8, U+7cec, U+7cf0, U+7cf5-7cf9, U+7cfc, U+7cfe, U+7d00, U+7d04-7d0b, U+7d0d, U+7d10-7d14, U+7d17-7d19, U+7d1b-7d1f, U+7d21, U+7d24-7d26, U+7d28-7d2a, U+7d2c-7d2e, U+7d30-7d31, U+7d33, U+7d35-7d36, U+7d38-7d3a, U+7d40, U+7d42-7d44, U+7d46, U+7d4b-7d4c, U+7d4f, U+7d51, U+7d54-7d56, U+7d58, U+7d5b-7d5c, U+7d5e, U+7d61-7d63, U+7d66, U+7d68, U+7d6a-7d6c, U+7d6f, U+7d71-7d73, U+7d75-7d77, U+7d79-7d7a, U+7d7e, U+7d81, U+7d84-7d8b, U+7d8d, U+7d8f, U+7d91, U+7d94, U+7d96, U+7d98-7d9a, U+7d9c-7da0, U+7da2, U+7da6, U+7daa-7db1, U+7db4-7db8, U+7dba-7dbf, U+7dc1, U+7dc4, U+7dc7-7dc8, U+7dca-7dcd, U+7dcf, U+7dd1; } /* [47] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.47.woff2) format("woff2"); unicode-range: U+7bd3-7bd4, U+7bd9-7bda, U+7bdd, U+7be0-7be1, U+7be4-7be6, U+7be9-7bea, U+7bef, U+7bf4, U+7bf6, U+7bfc, U+7bfe, U+7c01, U+7c03, U+7c07-7c08, U+7c0a-7c0d, U+7c0f, U+7c11, U+7c15-7c16, U+7c19, U+7c1e-7c21, U+7c23-7c24, U+7c26, U+7c28-7c33, U+7c35, U+7c37-7c3b, U+7c3d-7c3e, U+7c40-7c41, U+7c43, U+7c47-7c48, U+7c4c, U+7c50, U+7c53-7c54, U+7c59, U+7c5f-7c60, U+7c63-7c65, U+7c6c, U+7c6e, U+7c72, U+7c74, U+7c79-7c7a, U+7c7c, U+7c81-7c82, U+7c84-7c85, U+7c88, U+7c8a-7c91, U+7c93-7c96, U+7c99, U+7c9b-7c9e, U+7ca0-7ca2, U+7ca6-7ca9, U+7cac, U+7caf-7cb3, U+7cb5-7cb7, U+7cba-7cbd, U+7cbf-7cc2, U+7cc5, U+7cc7-7cc9, U+7ccc-7ccd, U+7cd7, U+7cdc, U+7cde, U+7ce0, U+7ce4-7ce5, U+7ce7; } /* [48] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.48.woff2) format("woff2"); unicode-range: U+7ae6, U+7af4-7af7, U+7afa-7afb, U+7afd-7b0a, U+7b0c, U+7b0e-7b0f, U+7b13, U+7b15-7b16, U+7b18-7b19, U+7b1e-7b20, U+7b22-7b25, U+7b29-7b2b, U+7b2d-7b2e, U+7b30-7b3b, U+7b3e-7b3f, U+7b41-7b42, U+7b44-7b47, U+7b4a, U+7b4c-7b50, U+7b58, U+7b5a, U+7b5c, U+7b60, U+7b66-7b67, U+7b69, U+7b6c-7b6f, U+7b72-7b76, U+7b7b-7b7d, U+7b7f, U+7b82, U+7b85, U+7b87, U+7b8b-7b96, U+7b98-7b99, U+7b9b-7b9f, U+7ba2-7ba4, U+7ba6-7bac, U+7bae-7bb0, U+7bb4, U+7bb7-7bb9, U+7bbb, U+7bc0-7bc1, U+7bc3-7bc4, U+7bc6, U+7bc8-7bcc, U+7bd1; } /* [49] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.49.woff2) format("woff2"); unicode-range: U+798b-798e, U+7992, U+7994-7995, U+7997-7998, U+799a-799c, U+799f, U+79a3-79a6, U+79a8-79ac, U+79ae-79b1, U+79b3-79b5, U+79b8, U+79ba, U+79bf, U+79c2, U+79c6, U+79c8, U+79cf, U+79d5-79d6, U+79dd-79de, U+79e3, U+79e7-79e8, U+79eb, U+79ed, U+79f4, U+79f7-79f8, U+79fa, U+79fe, U+7a02-7a03, U+7a05, U+7a0a, U+7a14, U+7a17, U+7a19, U+7a1c, U+7a1e-7a1f, U+7a23, U+7a25-7a26, U+7a2c, U+7a2e, U+7a30-7a32, U+7a36-7a37, U+7a39, U+7a3c, U+7a40, U+7a42, U+7a47, U+7a49, U+7a4c-7a4f, U+7a51, U+7a55, U+7a5b, U+7a5d-7a5e, U+7a62-7a63, U+7a66, U+7a68-7a69, U+7a6b, U+7a70, U+7a78, U+7a80, U+7a85-7a88, U+7a8a, U+7a90, U+7a93-7a96, U+7a98, U+7a9b-7a9c, U+7a9e, U+7aa0-7aa1, U+7aa3, U+7aa8-7aaa, U+7aac-7ab0, U+7ab3, U+7ab8, U+7aba, U+7abd-7abf, U+7ac4-7ac5, U+7ac7-7ac8, U+7aca, U+7ad1-7ad2, U+7ada-7add, U+7ae1, U+7ae4; } /* [50] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.50.woff2) format("woff2"); unicode-range: U+784c, U+784e-7854, U+7856-7857, U+7859-785a, U+7865, U+7869-786a, U+786d, U+786f, U+7876-7877, U+787c, U+787e-787f, U+7881, U+7887-7889, U+7893-7894, U+7898-789e, U+78a1, U+78a3, U+78a5, U+78a9, U+78ad, U+78b2, U+78b4, U+78b6, U+78b9-78ba, U+78bc, U+78bf, U+78c3, U+78c9, U+78cb, U+78d0-78d2, U+78d4, U+78d9-78da, U+78dc, U+78de, U+78e1, U+78e5-78e6, U+78ea, U+78ec, U+78ef, U+78f1-78f2, U+78f4, U+78fa-78fb, U+78fe, U+7901-7902, U+7905, U+7907, U+7909, U+790b-790c, U+790e, U+7910, U+7913, U+7919-791b, U+791e-791f, U+7921, U+7924, U+7926, U+792a-792b, U+7934, U+7936, U+7939, U+793b, U+793d, U+7940, U+7942-7943, U+7945-7947, U+7949-794a, U+794c, U+794e-7951, U+7953-7955, U+7957-795a, U+795c, U+795f-7960, U+7962, U+7964, U+7966-7967, U+7969, U+796b, U+796f, U+7972, U+7974, U+7979, U+797b-797c, U+797e-7980, U+7982, U+7986-7987, U+7989-798a; } /* [51] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.51.woff2) format("woff2"); unicode-range: U+7722, U+7726, U+7728, U+772b-7730, U+7732-7736, U+7739-773a, U+773d-773f, U+7743, U+7746-7747, U+774c-774f, U+7751-7752, U+7758-775a, U+775c-775e, U+7762, U+7765-7766, U+7768-776a, U+776c-776d, U+7771-7772, U+777a, U+777c-777e, U+7780, U+7785, U+7787, U+778b-778d, U+778f-7791, U+7793, U+779e-77a0, U+77a2, U+77a5, U+77ad, U+77af, U+77b4-77b7, U+77bd-77c0, U+77c2, U+77c5, U+77c7, U+77cd, U+77d6-77d7, U+77d9-77da, U+77dd-77de, U+77e7, U+77ea, U+77ec, U+77ef, U+77f8, U+77fb, U+77fd-77fe, U+7800, U+7803, U+7806, U+7809, U+780f-7812, U+7815, U+7817-7818, U+781a-781f, U+7821-7823, U+7825-7827, U+7829, U+782b-7830, U+7832-7833, U+7835, U+7837, U+7839-783c, U+783e, U+7841-7844, U+7847-7849, U+784b; } /* [52] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.52.woff2) format("woff2"); unicode-range: U+7613-7619, U+761b-761d, U+761f-7622, U+7625, U+7627-762a, U+762e-7630, U+7632-7635, U+7638-763a, U+763c-763d, U+763f-7640, U+7642-7643, U+7647-7648, U+764d-764e, U+7652, U+7654, U+7658, U+765a, U+765c, U+765e-765f, U+7661-7663, U+7665, U+7669, U+766c, U+766e-766f, U+7671-7673, U+7675-7676, U+7678-767a, U+767f, U+7681, U+7683, U+7688, U+768a-768c, U+768e, U+7690-7692, U+7695, U+7698, U+769a-769b, U+769d-76a0, U+76a2, U+76a4-76a7, U+76ab-76ac, U+76af-76b0, U+76b2, U+76b4-76b5, U+76ba-76bb, U+76bf, U+76c2-76c3, U+76c5, U+76c9, U+76cc-76ce, U+76dc-76de, U+76e1-76ea, U+76f1, U+76f9-76fb, U+76fd, U+76ff-7700, U+7703-7704, U+7707-7708, U+770c-770f, U+7712, U+7714, U+7716, U+7719-771b, U+771e, U+7721; } /* [53] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.53.woff2) format("woff2"); unicode-range: U+750d, U+750f, U+7511, U+7513, U+7515, U+7517, U+7519, U+7521-7527, U+752a, U+752c-752d, U+752f, U+7534, U+7536, U+753a, U+753e, U+7540, U+7544, U+7547-754b, U+754d-754e, U+7550-7553, U+7556-7557, U+755a-755b, U+755d-755e, U+7560, U+7562, U+7564, U+7566-7568, U+756b-756c, U+756f-7573, U+7575, U+7579-757c, U+757e-757f, U+7581-7584, U+7587, U+7589-758e, U+7590, U+7592, U+7594, U+7596, U+7599-759a, U+759d, U+759f-75a0, U+75a3, U+75a5, U+75a8, U+75ac-75ad, U+75b0-75b1, U+75b3-75b5, U+75b8, U+75bd, U+75c1-75c4, U+75c8-75ca, U+75cc-75cd, U+75d4, U+75d6, U+75d9, U+75de, U+75e0, U+75e2-75e4, U+75e6-75ea, U+75f1-75f3, U+75f7, U+75f9-75fa, U+75fc, U+75fe-7601, U+7603, U+7605-7606, U+7608-760e, U+7610-7612; } /* [54] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.54.woff2) format("woff2"); unicode-range: U+73f0, U+73f2, U+73f4-73f5, U+73f7, U+73f9-73fa, U+73fc-73fd, U+73ff-7402, U+7404, U+7407-7408, U+740a-740f, U+7418, U+741a-741c, U+741e, U+7424-7425, U+7428-7429, U+742c-7430, U+7432, U+7435-7436, U+7438-743b, U+743e-7441, U+7443-7446, U+7448, U+744a-744b, U+7452, U+7457, U+745b, U+745d, U+7460, U+7462-7465, U+7467-746a, U+746d, U+746f, U+7471, U+7473-7474, U+7477, U+747a, U+747e, U+7481-7482, U+7484, U+7486, U+7488-748b, U+748e-748f, U+7493, U+7498, U+749a, U+749c-74a0, U+74a3, U+74a6, U+74a9-74aa, U+74ae, U+74b0-74b2, U+74b6, U+74b8-74ba, U+74bd, U+74bf, U+74c1, U+74c3, U+74c5, U+74c8, U+74ca, U+74cc, U+74cf, U+74d1-74d2, U+74d4-74d5, U+74d8-74db, U+74de-74e0, U+74e2, U+74e4-74e5, U+74e7-74e9, U+74ee-74ef, U+74f4, U+74ff, U+7501, U+7503, U+7505, U+7508; } /* [55] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.55.woff2) format("woff2"); unicode-range: U+72e6, U+72e8, U+72ef-72f0, U+72f2-72f4, U+72f6-72f7, U+72f9-72fb, U+72fd, U+7300-7304, U+7307, U+730a-730c, U+7313-7317, U+731d-7322, U+7327, U+7329, U+732c-732d, U+7330-7331, U+7333, U+7335-7337, U+7339, U+733d-733e, U+7340, U+7342, U+7344-7345, U+734a, U+734d-7350, U+7352, U+7355, U+7357, U+7359, U+735f-7360, U+7362-7363, U+7365, U+7368, U+736c-736d, U+736f-7370, U+7372, U+7374-7376, U+7378, U+737a-737b, U+737d-737e, U+7382-7383, U+7386, U+7388, U+738a, U+738c-7393, U+7395, U+7397-739a, U+739c, U+739e, U+73a0-73a3, U+73a5-73a8, U+73aa, U+73ad, U+73b1, U+73b3, U+73b6-73b7, U+73b9, U+73c2, U+73c5-73c9, U+73cc, U+73ce-73d0, U+73d2, U+73d6, U+73d9, U+73db-73de, U+73e3, U+73e5-73ea, U+73ee-73ef; } /* [56] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.56.woff2) format("woff2"); unicode-range: U+71a8, U+71af, U+71b1-71bc, U+71be, U+71c1-71c2, U+71c4, U+71c8-71cb, U+71ce-71d0, U+71d2, U+71d4, U+71d9-71da, U+71dc, U+71df-71e0, U+71e6-71e8, U+71ea, U+71ed-71ee, U+71f4, U+71f6, U+71f9, U+71fb-71fc, U+71ff-7200, U+7207, U+720c-720d, U+7210, U+7216, U+721a-721e, U+7223, U+7228, U+722b, U+722d-722e, U+7230, U+7232, U+723a-723c, U+723e-7242, U+7246, U+724b, U+724d-724e, U+7252, U+7256, U+7258, U+725a, U+725c-725d, U+7260, U+7264-7266, U+726a, U+726c, U+726e-726f, U+7271, U+7273-7274, U+7278, U+727b, U+727d-727e, U+7281-7282, U+7284, U+7287, U+728a, U+728d, U+728f, U+7292, U+729b, U+729f-72a0, U+72a7, U+72ad-72ae, U+72b0-72b5, U+72b7-72b8, U+72ba-72be, U+72c0-72c1, U+72c3, U+72c5-72c6, U+72c8, U+72cc-72ce, U+72d2, U+72d6, U+72db, U+72dd-72df, U+72e1, U+72e5; } /* [57] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.57.woff2) format("woff2"); unicode-range: U+700b, U+700d, U+7015, U+7018, U+701b, U+701d-701f, U+7023, U+7026-7028, U+702c, U+702e-7030, U+7035, U+7037, U+7039-703a, U+703c-703e, U+7044, U+7049-704b, U+704f, U+7051, U+7058, U+705a, U+705c-705e, U+7061, U+7064, U+7066, U+706c, U+707d, U+7080-7081, U+7085-7086, U+708a, U+708f, U+7091, U+7094-7095, U+7098-7099, U+709c-709d, U+709f, U+70a4, U+70a9-70aa, U+70af-70b2, U+70b4-70b7, U+70bb, U+70c0, U+70c3, U+70c7, U+70cb, U+70ce-70cf, U+70d4, U+70d9-70da, U+70dc-70dd, U+70e0, U+70e9, U+70ec, U+70f7, U+70fa, U+70fd, U+70ff, U+7104, U+7108-7109, U+710c, U+7110, U+7113-7114, U+7116-7118, U+711c, U+711e, U+7120, U+712e-712f, U+7131, U+713c, U+7142, U+7144-7147, U+7149-714b, U+7150, U+7152, U+7155-7156, U+7159-715a, U+715c, U+7161, U+7165-7166, U+7168-7169, U+716d, U+7173-7174, U+7176, U+7178, U+717a, U+717d, U+717f-7180, U+7184, U+7186-7188, U+7192, U+7198, U+719c, U+71a0, U+71a4-71a5; } /* [58] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.58.woff2) format("woff2"); unicode-range: U+6ed9, U+6edb, U+6edd, U+6edf-6ee0, U+6ee2, U+6ee6, U+6eea, U+6eec, U+6eee-6eef, U+6ef2-6ef3, U+6ef7-6efa, U+6efe, U+6f01, U+6f03, U+6f08-6f09, U+6f15-6f16, U+6f19, U+6f22-6f25, U+6f28-6f2a, U+6f2c-6f2d, U+6f2f, U+6f32, U+6f36-6f38, U+6f3f, U+6f43-6f46, U+6f48, U+6f4b, U+6f4e-6f4f, U+6f51, U+6f54-6f57, U+6f59-6f5b, U+6f5e-6f5f, U+6f61, U+6f64-6f67, U+6f69-6f6c, U+6f6f-6f72, U+6f74-6f76, U+6f78-6f7e, U+6f80-6f83, U+6f86, U+6f89, U+6f8b-6f8d, U+6f90, U+6f92, U+6f94, U+6f97-6f98, U+6f9b, U+6fa3-6fa5, U+6fa7, U+6faa, U+6faf, U+6fb1, U+6fb4, U+6fb6, U+6fb9, U+6fc1-6fcb, U+6fd1-6fd3, U+6fd5, U+6fdb, U+6fde-6fe1, U+6fe4, U+6fe9, U+6feb-6fec, U+6fee-6ff1, U+6ffa, U+6ffe, U+7005-7006, U+7009; } /* [59] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.59.woff2) format("woff2"); unicode-range: U+6dc3, U+6dc5-6dc6, U+6dc9, U+6dcc, U+6dcf, U+6dd2-6dd3, U+6dd6, U+6dd9-6dde, U+6de0, U+6de4, U+6de6, U+6de8-6dea, U+6dec, U+6def-6df0, U+6df5-6df6, U+6df8, U+6dfa, U+6dfc, U+6e03-6e04, U+6e07-6e09, U+6e0b-6e0c, U+6e0e, U+6e11, U+6e13, U+6e15-6e16, U+6e19-6e1b, U+6e1e-6e1f, U+6e22, U+6e25-6e27, U+6e2b-6e2c, U+6e36-6e37, U+6e39-6e3a, U+6e3c-6e41, U+6e44-6e45, U+6e47, U+6e49-6e4b, U+6e4d-6e4e, U+6e51, U+6e53-6e55, U+6e5c-6e5f, U+6e61-6e63, U+6e65-6e67, U+6e6a-6e6b, U+6e6d-6e70, U+6e72-6e74, U+6e76-6e78, U+6e7c, U+6e80-6e82, U+6e86-6e87, U+6e89, U+6e8d, U+6e8f, U+6e96, U+6e98, U+6e9d-6e9f, U+6ea1, U+6ea5-6ea7, U+6eab, U+6eb1-6eb2, U+6eb4, U+6eb7, U+6ebb-6ebd, U+6ebf-6ec6, U+6ec8-6ec9, U+6ecc, U+6ecf-6ed0, U+6ed3-6ed4, U+6ed7-6ed8; } /* [60] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.60.woff2) format("woff2"); unicode-range: U+6cb1-6cb2, U+6cb4-6cb5, U+6cb7, U+6cba, U+6cbc-6cbd, U+6cc1-6cc3, U+6cc5-6cc7, U+6cd0-6cd4, U+6cd6-6cd7, U+6cd9-6cda, U+6cde-6ce0, U+6ce4, U+6ce6, U+6ce9, U+6ceb-6cef, U+6cf1-6cf2, U+6cf6-6cf7, U+6cfa, U+6cfe, U+6d03-6d05, U+6d07-6d08, U+6d0a, U+6d0c, U+6d0e-6d11, U+6d13-6d14, U+6d16, U+6d18-6d1a, U+6d1c, U+6d1f, U+6d22-6d23, U+6d26-6d29, U+6d2b, U+6d2e-6d30, U+6d33, U+6d35-6d36, U+6d38-6d3a, U+6d3c, U+6d3f, U+6d42-6d44, U+6d48-6d49, U+6d4d, U+6d50, U+6d52, U+6d54, U+6d56-6d58, U+6d5a-6d5c, U+6d5e, U+6d60-6d61, U+6d63-6d65, U+6d67, U+6d6c-6d6d, U+6d6f, U+6d75, U+6d7b-6d7d, U+6d87, U+6d8a, U+6d8e, U+6d90-6d9a, U+6d9c-6da0, U+6da2-6da3, U+6da7, U+6daa-6dac, U+6dae, U+6db3-6db4, U+6db6, U+6db8, U+6dbc, U+6dbf, U+6dc2; } /* [61] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.61.woff2) format("woff2"); unicode-range: U+6b83-6b86, U+6b89, U+6b8d, U+6b91-6b93, U+6b95, U+6b97-6b98, U+6b9a-6b9b, U+6b9e, U+6ba1-6ba4, U+6ba9-6baa, U+6bad, U+6baf-6bb0, U+6bb2-6bb3, U+6bba-6bbd, U+6bc0, U+6bc2, U+6bc6, U+6bca-6bcc, U+6bce, U+6bd0-6bd1, U+6bd3, U+6bd6-6bd8, U+6bda, U+6be1, U+6be6, U+6bec, U+6bf1, U+6bf3-6bf5, U+6bf9, U+6bfd, U+6c05-6c08, U+6c0d, U+6c10, U+6c15-6c1a, U+6c21, U+6c23-6c26, U+6c29-6c2d, U+6c30-6c33, U+6c35-6c37, U+6c39-6c3a, U+6c3c-6c3f, U+6c46, U+6c4a-6c4c, U+6c4e-6c50, U+6c54, U+6c56, U+6c59-6c5c, U+6c5e, U+6c63, U+6c67-6c69, U+6c6b, U+6c6d, U+6c6f, U+6c72-6c74, U+6c78-6c7a, U+6c7c, U+6c84-6c87, U+6c8b-6c8c, U+6c8f, U+6c91, U+6c93-6c96, U+6c98, U+6c9a, U+6c9d, U+6ca2-6ca4, U+6ca8-6ca9, U+6cac-6cae; } /* [62] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.62.woff2) format("woff2"); unicode-range: U+69fe-6a01, U+6a06, U+6a09, U+6a0b, U+6a11, U+6a13, U+6a17-6a19, U+6a1b, U+6a1e, U+6a23, U+6a28-6a29, U+6a2b, U+6a2f-6a30, U+6a35, U+6a38-6a40, U+6a46-6a48, U+6a4a-6a4b, U+6a4e, U+6a50, U+6a52, U+6a5b, U+6a5e, U+6a62, U+6a65-6a67, U+6a6b, U+6a79, U+6a7c, U+6a7e-6a7f, U+6a84, U+6a86, U+6a8e, U+6a90-6a91, U+6a94, U+6a97, U+6a9c, U+6a9e, U+6aa0, U+6aa2, U+6aa4, U+6aa9, U+6aab, U+6aae-6ab0, U+6ab2-6ab3, U+6ab5, U+6ab7-6ab8, U+6aba-6abb, U+6abd, U+6abf, U+6ac2-6ac4, U+6ac6, U+6ac8, U+6acc, U+6ace, U+6ad2-6ad3, U+6ad8-6adc, U+6adf-6ae0, U+6ae4-6ae5, U+6ae7-6ae8, U+6afb, U+6b04-6b05, U+6b0d-6b13, U+6b16-6b17, U+6b19, U+6b24-6b25, U+6b2c, U+6b37-6b39, U+6b3b, U+6b3d, U+6b43, U+6b46, U+6b4e, U+6b50, U+6b53-6b54, U+6b58-6b59, U+6b5b, U+6b60, U+6b69, U+6b6d, U+6b6f-6b70, U+6b73-6b74, U+6b77-6b7a, U+6b80-6b82; } /* [63] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.63.woff2) format("woff2"); unicode-range: U+68d3, U+68d7, U+68dd, U+68df, U+68e1, U+68e3-68e4, U+68e6-68ed, U+68ef-68f0, U+68f2, U+68f4, U+68f6-68f7, U+68f9, U+68fb-68fd, U+68ff-6902, U+6906-6908, U+690b, U+6910, U+691a-691c, U+691f-6920, U+6924-6925, U+692a, U+692d, U+6934, U+6939, U+693c-6945, U+694a-694b, U+6952-6954, U+6957, U+6959, U+695b, U+695d, U+695f, U+6962-6964, U+6966, U+6968-696c, U+696e-696f, U+6971, U+6973-6974, U+6978-6979, U+697d, U+697f-6980, U+6985, U+6987-698a, U+698d-698e, U+6994-6999, U+699b, U+69a3-69a4, U+69a6-69a7, U+69ab, U+69ad-69ae, U+69b1, U+69b7, U+69bb-69bc, U+69c1, U+69c3-69c5, U+69c7, U+69ca-69ce, U+69d0-69d1, U+69d3-69d4, U+69d7-69da, U+69e0, U+69e4, U+69e6, U+69ec-69ed, U+69f1-69f3, U+69f8, U+69fa-69fc; } /* [64] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.64.woff2) format("woff2"); unicode-range: U+678b-678d, U+678f, U+6792-6793, U+6796, U+6798, U+679e-67a1, U+67a5, U+67a7-67a9, U+67ac-67ad, U+67b0-67b1, U+67b3, U+67b5, U+67b7, U+67b9, U+67bb-67bc, U+67c0-67c1, U+67c3, U+67c5-67ca, U+67d1-67d2, U+67d7-67d9, U+67dd-67df, U+67e2-67e4, U+67e6-67e9, U+67f0, U+67f5, U+67f7-67f8, U+67fa-67fb, U+67fd-67fe, U+6800-6801, U+6803-6804, U+6806, U+6809-680a, U+680c, U+680e, U+6812, U+681d-681f, U+6822, U+6824-6829, U+682b-682d, U+6831-6835, U+683b, U+683e, U+6840-6841, U+6844-6845, U+6849, U+684e, U+6853, U+6855-6856, U+685c-685d, U+685f-6862, U+6864, U+6866-6868, U+686b, U+686f, U+6872, U+6874, U+6877, U+687f, U+6883, U+6886, U+688f, U+689b, U+689f-68a0, U+68a2-68a3, U+68b1, U+68b6, U+68b9-68ba, U+68bc-68bf, U+68c1-68c4, U+68c6, U+68c8, U+68ca, U+68cc, U+68d0-68d1; } /* [65] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.65.woff2) format("woff2"); unicode-range: U+6631, U+6633-6634, U+6636, U+663a-663b, U+663d, U+6641, U+6644-6645, U+6649, U+664c, U+664f, U+6654, U+6659, U+665b, U+665d-665e, U+6660-6667, U+6669, U+666b-666c, U+6671, U+6673, U+6677-6679, U+667c, U+6680-6681, U+6684-6685, U+6688-6689, U+668b-668e, U+6690, U+6692, U+6695, U+6698, U+669a, U+669d, U+669f-66a0, U+66a2-66a3, U+66a6, U+66aa-66ab, U+66b1-66b2, U+66b5, U+66b8-66b9, U+66bb, U+66be, U+66c1, U+66c6-66c9, U+66cc, U+66d5-66d8, U+66da-66dc, U+66de-66e2, U+66e8-66ea, U+66ec, U+66f1, U+66f3, U+66f7, U+66fa, U+66fd, U+6702, U+6705, U+670a, U+670f-6710, U+6713, U+6715, U+6719, U+6722-6723, U+6725-6727, U+6729, U+672d-672e, U+6732-6733, U+6736, U+6739, U+673b, U+673f, U+6744, U+6748, U+674c-674d, U+6753, U+6755, U+6762, U+6767, U+6769-676c, U+676e, U+6772-6773, U+6775, U+6777, U+677a-677d, U+6782-6783, U+6787, U+678a; } /* [66] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.66.woff2) format("woff2"); unicode-range: U+64f1-64f2, U+64f4, U+64f7-64f8, U+64fa, U+64fc, U+64fe-64ff, U+6503, U+6509, U+650f, U+6514, U+6518, U+651c-651e, U+6522-6525, U+652a-652c, U+652e, U+6530-6532, U+6534-6535, U+6537-6538, U+653a, U+653c-653d, U+6542, U+6549-654b, U+654d-654e, U+6553-6555, U+6557-6558, U+655d, U+6564, U+6569, U+656b, U+656d-656f, U+6571, U+6573, U+6575-6576, U+6578-657e, U+6581-6583, U+6585-6586, U+6589, U+658e-658f, U+6592-6593, U+6595-6596, U+659b, U+659d, U+659f-65a1, U+65a3, U+65ab-65ac, U+65b2, U+65b6-65b7, U+65ba-65bb, U+65be-65c0, U+65c2-65c4, U+65c6-65c8, U+65cc, U+65ce, U+65d0, U+65d2-65d3, U+65d6, U+65db, U+65dd, U+65e1, U+65e3, U+65ee-65f0, U+65f3-65f5, U+65f8, U+65fb-65fc, U+65fe-6600, U+6603, U+6607, U+6609, U+660b, U+6610-6611, U+6619-661a, U+661c-661e, U+6621, U+6624, U+6626, U+662a-662c, U+662e, U+6630; } /* [67] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.67.woff2) format("woff2"); unicode-range: U+63b8-63bc, U+63be, U+63c0, U+63c3-63c4, U+63c6, U+63c8, U+63cd-63ce, U+63d1, U+63d6, U+63da-63db, U+63de, U+63e0, U+63e3, U+63e9-63ea, U+63ee, U+63f2, U+63f5-63fa, U+63fc, U+63fe-6400, U+6406, U+640b-640d, U+6410, U+6414, U+6416-6417, U+641b, U+6420-6423, U+6425-6428, U+642a, U+6431-6432, U+6434-6437, U+643d-6442, U+6445, U+6448, U+6450-6452, U+645b-645f, U+6462, U+6465, U+6468, U+646d, U+646f-6471, U+6473, U+6477, U+6479-647d, U+6482-6485, U+6487-6488, U+648c, U+6490, U+6493, U+6496-649a, U+649d, U+64a0, U+64a5, U+64ab-64ac, U+64b1-64b7, U+64b9-64bb, U+64be-64c1, U+64c4, U+64c7, U+64c9-64cb, U+64d0, U+64d4, U+64d7-64d8, U+64da, U+64de, U+64e0-64e2, U+64e4, U+64e9, U+64ec, U+64f0; } /* [68] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.68.woff2) format("woff2"); unicode-range: U+622c, U+622e-6230, U+6232, U+6238, U+623b, U+623d-623e, U+6243, U+6246, U+6248-6249, U+624c, U+6255, U+6259, U+625e, U+6260-6261, U+6265-6266, U+626a, U+6271, U+627a, U+627c-627d, U+6283, U+6286, U+6289, U+628e, U+6294, U+629c, U+629e-629f, U+62a1, U+62a8, U+62ba-62bb, U+62bf, U+62c2, U+62c4, U+62c8, U+62ca-62cb, U+62cf, U+62d1, U+62d7, U+62d9-62da, U+62dd, U+62e0-62e1, U+62e3-62e4, U+62e7, U+62eb, U+62ee, U+62f0, U+62f4-62f6, U+6308, U+630a-630e, U+6310, U+6312-6313, U+6317, U+6319, U+631b, U+631d-631f, U+6322, U+6326, U+6329, U+6331-6332, U+6334-6337, U+6339, U+633b-633c, U+633e-6340, U+6343, U+6347, U+634b-634e, U+6354, U+635c-635d, U+6368-6369, U+636d, U+636f-6372, U+6376, U+637a-637b, U+637d, U+6382-6383, U+6387, U+638a-638b, U+638d-638e, U+6391, U+6393-6397, U+6399, U+639b, U+639e-639f, U+63a1, U+63a3-63a4, U+63ac-63ae, U+63b1-63b5; } /* [69] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.69.woff2) format("woff2"); unicode-range: U+60ed-60ee, U+60f0-60f1, U+60f4, U+60f6, U+60fa, U+6100, U+6106, U+610d-610e, U+6112, U+6114-6115, U+6119, U+611c, U+6120, U+6122-6123, U+6126, U+6128-6130, U+6136-6137, U+613a, U+613d-613e, U+6144, U+6146-6147, U+614a-614b, U+6151, U+6153, U+6158, U+615a, U+615c-615d, U+615f, U+6161, U+6163-6165, U+616b-616c, U+616e, U+6171, U+6173-6177, U+617e, U+6182, U+6187, U+618a, U+618d-618e, U+6190-6191, U+6194, U+6199-619a, U+619c, U+619f, U+61a1, U+61a3-61a4, U+61a7-61a9, U+61ab-61ad, U+61b2-61b3, U+61b5-61b7, U+61ba-61bb, U+61bf, U+61c3-61c4, U+61c6-61c7, U+61c9-61cb, U+61d0-61d1, U+61d3-61d4, U+61d7, U+61da, U+61df-61e1, U+61e6, U+61ee, U+61f0, U+61f2, U+61f6-61f8, U+61fa, U+61fc-61fe, U+6200, U+6206-6207, U+6209, U+620b, U+620d-620e, U+6213-6215, U+6217, U+6219, U+621b-6223, U+6225-6226; } /* [70] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.70.woff2) format("woff2"); unicode-range: U+5fc4, U+5fc9, U+5fcb, U+5fce-5fd6, U+5fda-5fde, U+5fe1-5fe2, U+5fe4-5fe5, U+5fea, U+5fed-5fee, U+5ff1-5ff3, U+5ff6, U+5ff8, U+5ffb, U+5ffe-5fff, U+6002-6006, U+600a, U+600d, U+600f, U+6014, U+6019, U+601b, U+6020, U+6023, U+6026, U+6029, U+602b, U+602e-602f, U+6031, U+6033, U+6035, U+6039, U+603f, U+6041-6043, U+6046, U+604f, U+6053-6054, U+6058-605b, U+605d-605e, U+6060, U+6063, U+6065, U+6067, U+606a-606c, U+6075, U+6078-6079, U+607b, U+607d, U+607f, U+6083, U+6085-6087, U+608a, U+608c, U+608e-608f, U+6092-6093, U+6095-6097, U+609b-609d, U+60a2, U+60a7, U+60a9-60ab, U+60ad, U+60af-60b1, U+60b3-60b6, U+60b8, U+60bb, U+60bd-60be, U+60c0-60c3, U+60c6-60c9, U+60cb, U+60ce, U+60d3-60d4, U+60d7-60db, U+60dd, U+60e1-60e4, U+60e6, U+60ea, U+60ec; } /* [71] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.71.woff2) format("woff2"); unicode-range: U+5e98, U+5e9b, U+5e9d, U+5ea0-5ea5, U+5ea8, U+5eab, U+5eaf, U+5eb3, U+5eb5-5eb6, U+5eb9, U+5ebe, U+5ec1-5ec3, U+5ec6, U+5ec8, U+5ecb-5ecc, U+5ed1-5ed2, U+5ed4, U+5ed9-5edb, U+5edd, U+5edf-5ee0, U+5ee2-5ee3, U+5ee8, U+5eea, U+5eec, U+5eef-5ef0, U+5ef3-5ef4, U+5ef8, U+5efb-5efc, U+5efe-5eff, U+5f01, U+5f07, U+5f0b-5f0e, U+5f10-5f12, U+5f14, U+5f1a, U+5f22, U+5f28-5f29, U+5f2c-5f2d, U+5f35-5f36, U+5f38, U+5f3b-5f43, U+5f45-5f4a, U+5f4c-5f4e, U+5f50, U+5f54, U+5f56-5f59, U+5f5b-5f5f, U+5f61, U+5f63, U+5f65, U+5f67-5f68, U+5f6b, U+5f6e-5f6f, U+5f72-5f78, U+5f7a, U+5f7e-5f7f, U+5f82-5f83, U+5f87, U+5f89-5f8a, U+5f8d, U+5f91, U+5f93, U+5f95, U+5f98-5f99, U+5f9c, U+5f9e, U+5fa0, U+5fa6-5fa9, U+5fac-5fad, U+5faf, U+5fb3-5fb5, U+5fb9, U+5fbc; } /* [72] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.72.woff2) format("woff2"); unicode-range: U+5d26-5d27, U+5d2e-5d34, U+5d3c-5d3e, U+5d41-5d44, U+5d46-5d48, U+5d4a-5d4b, U+5d4e, U+5d50, U+5d52, U+5d55-5d58, U+5d5a-5d5d, U+5d68-5d69, U+5d6b-5d6c, U+5d6f, U+5d74, U+5d7f, U+5d82-5d89, U+5d8b-5d8c, U+5d8f, U+5d92-5d93, U+5d99, U+5d9d, U+5db2, U+5db6-5db7, U+5dba, U+5dbc-5dbd, U+5dc2-5dc3, U+5dc6-5dc7, U+5dc9, U+5dcc, U+5dd2, U+5dd4, U+5dd6-5dd8, U+5ddb-5ddc, U+5de3, U+5ded, U+5def, U+5df3, U+5df6, U+5dfa-5dfd, U+5dff-5e00, U+5e07, U+5e0f, U+5e11, U+5e13-5e14, U+5e19-5e1b, U+5e22, U+5e25, U+5e28, U+5e2a, U+5e2f-5e31, U+5e33-5e34, U+5e36, U+5e39-5e3c, U+5e3e, U+5e40, U+5e44, U+5e46-5e48, U+5e4c, U+5e4f, U+5e53-5e54, U+5e57, U+5e59, U+5e5b, U+5e5e-5e5f, U+5e61, U+5e63, U+5e6a-5e6b, U+5e75, U+5e77, U+5e79-5e7a, U+5e7e, U+5e80-5e81, U+5e83, U+5e85, U+5e87, U+5e8b, U+5e91-5e92, U+5e96; } /* [73] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.73.woff2) format("woff2"); unicode-range: U+5bec, U+5bee-5bf0, U+5bf2-5bf3, U+5bf5-5bf6, U+5bfe, U+5c02-5c03, U+5c05, U+5c07-5c09, U+5c0b-5c0c, U+5c0e, U+5c10, U+5c12-5c13, U+5c15, U+5c17, U+5c19, U+5c1b-5c1c, U+5c1e-5c1f, U+5c22, U+5c25, U+5c28, U+5c2a-5c2b, U+5c2f-5c30, U+5c37, U+5c3b, U+5c43-5c44, U+5c46-5c47, U+5c4d, U+5c50, U+5c59, U+5c5b-5c5c, U+5c62-5c64, U+5c66, U+5c6c, U+5c6e, U+5c74, U+5c78-5c7e, U+5c80, U+5c83-5c84, U+5c88, U+5c8b-5c8d, U+5c91, U+5c94-5c96, U+5c98-5c99, U+5c9c, U+5c9e, U+5ca1-5ca3, U+5cab-5cac, U+5cb1, U+5cb5, U+5cb7, U+5cba, U+5cbd-5cbf, U+5cc1, U+5cc3-5cc4, U+5cc7, U+5ccb, U+5cd2, U+5cd8-5cd9, U+5cdf-5ce0, U+5ce3-5ce6, U+5ce8-5cea, U+5ced, U+5cef, U+5cf3-5cf4, U+5cf6, U+5cf8, U+5cfd, U+5d00-5d04, U+5d06, U+5d08, U+5d0b-5d0d, U+5d0f-5d13, U+5d15, U+5d17-5d1a, U+5d1d-5d22, U+5d24-5d25; } /* [74] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.74.woff2) format("woff2"); unicode-range: U+5aa0, U+5aa3-5aa4, U+5aaa, U+5aae-5aaf, U+5ab1-5ab2, U+5ab4-5ab5, U+5ab7-5aba, U+5abd-5abf, U+5ac3-5ac4, U+5ac6-5ac8, U+5aca-5acb, U+5acd, U+5acf-5ad2, U+5ad4, U+5ad8-5ada, U+5adc, U+5adf-5ae2, U+5ae4, U+5ae6, U+5ae8, U+5aea-5aed, U+5af0-5af3, U+5af5, U+5af9-5afb, U+5afd, U+5b01, U+5b05, U+5b08, U+5b0b-5b0c, U+5b11, U+5b16-5b17, U+5b1b, U+5b21-5b22, U+5b24, U+5b27-5b2e, U+5b30, U+5b32, U+5b34, U+5b36-5b38, U+5b3e-5b40, U+5b43, U+5b45, U+5b4a-5b4b, U+5b51-5b53, U+5b56, U+5b5a-5b5b, U+5b62, U+5b65, U+5b67, U+5b6a-5b6e, U+5b70-5b71, U+5b73, U+5b7a-5b7b, U+5b7f-5b80, U+5b84, U+5b8d, U+5b91, U+5b93-5b95, U+5b9f, U+5ba5-5ba6, U+5bac, U+5bae, U+5bb8, U+5bc0, U+5bc3, U+5bcb, U+5bd0-5bd1, U+5bd4-5bd8, U+5bda-5bdc, U+5be2, U+5be4-5be5, U+5be7, U+5be9, U+5beb; } /* [75] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.75.woff2) format("woff2"); unicode-range: U+596a, U+596c-596e, U+5977, U+597b-597c, U+5981, U+598f, U+5997-5998, U+599a, U+599c-599d, U+59a0-59a1, U+59a3-59a4, U+59a7, U+59aa-59ad, U+59af, U+59b2-59b3, U+59b5-59b6, U+59b8, U+59ba, U+59bd-59be, U+59c0-59c1, U+59c3-59c4, U+59c7-59ca, U+59cc-59cd, U+59cf, U+59d2, U+59d5-59d6, U+59d8-59d9, U+59db, U+59dd-59e0, U+59e2-59e7, U+59e9-59eb, U+59ee, U+59f1, U+59f3, U+59f5, U+59f7-59f9, U+59fd, U+5a06, U+5a08-5a0a, U+5a0c-5a0d, U+5a11-5a13, U+5a15-5a16, U+5a1a-5a1b, U+5a21-5a23, U+5a2d-5a2f, U+5a32, U+5a38, U+5a3c, U+5a3e-5a45, U+5a47, U+5a4a, U+5a4c-5a4d, U+5a4f-5a51, U+5a53, U+5a55-5a57, U+5a5e, U+5a60, U+5a62, U+5a65-5a67, U+5a6a, U+5a6c-5a6d, U+5a72-5a73, U+5a75-5a76, U+5a79-5a7c, U+5a81-5a84, U+5a8c, U+5a8e, U+5a93, U+5a96-5a97, U+5a9c, U+5a9e; } /* [76] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.76.woff2) format("woff2"); unicode-range: U+5820, U+5822-5823, U+5825-5826, U+582c, U+582f, U+5831, U+583a, U+583d, U+583f-5842, U+5844-5846, U+5848, U+584a, U+584d, U+5852, U+5857, U+5859-585a, U+585c-585d, U+5862, U+5868-5869, U+586c-586d, U+586f-5873, U+5875, U+5879, U+587d-587e, U+5880-5881, U+5888-588a, U+588d, U+5892, U+5896-5898, U+589a, U+589c-589d, U+58a0-58a1, U+58a3, U+58a6, U+58a9, U+58ab-58ae, U+58b0, U+58b3, U+58bb-58bf, U+58c2-58c3, U+58c5-58c8, U+58ca, U+58cc, U+58ce, U+58d1-58d3, U+58d5, U+58d8-58d9, U+58de-58df, U+58e2, U+58e9, U+58ec, U+58ef, U+58f1-58f2, U+58f5, U+58f7-58f8, U+58fa, U+58fd, U+5900, U+5902, U+5906, U+5908-590c, U+590e, U+5910, U+5914, U+5919, U+591b, U+591d-591e, U+5920, U+5922-5925, U+5928, U+592c-592d, U+592f, U+5932, U+5936, U+593c, U+593e, U+5940-5942, U+5944, U+594c-594d, U+5950, U+5953, U+5958, U+595a, U+5961, U+5966-5968; } /* [77] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.77.woff2) format("woff2"); unicode-range: U+56f9, U+56fc, U+56ff-5700, U+5703-5704, U+5709-570a, U+570c-570d, U+570f, U+5712-5713, U+5718-5719, U+571c, U+571e, U+5725, U+5727, U+5729-572a, U+572c, U+572e-572f, U+5734-5735, U+5739, U+573b, U+5741, U+5743, U+5745, U+5749, U+574c-574d, U+575c, U+5763, U+5768-5769, U+576b, U+576d-576e, U+5770, U+5773, U+5775, U+5777, U+577b-577c, U+5785-5786, U+5788, U+578c, U+578e-578f, U+5793-5795, U+5799-57a1, U+57a3-57a4, U+57a6-57aa, U+57ac-57ad, U+57af-57b2, U+57b4-57b6, U+57b8-57b9, U+57bd-57bf, U+57c2, U+57c4-57c8, U+57cc-57cd, U+57cf, U+57d2, U+57d5-57de, U+57e1-57e2, U+57e4-57e5, U+57e7, U+57eb, U+57ed, U+57ef, U+57f4-57f8, U+57fc-57fd, U+5800-5801, U+5803, U+5805, U+5807, U+5809, U+580b-580e, U+5811, U+5814, U+5819, U+581b-581f; } /* [78] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.78.woff2) format("woff2"); unicode-range: U+55f5-55f7, U+55fb, U+55fe, U+5600-5601, U+5605-5606, U+5608, U+560c-560d, U+560f, U+5614, U+5616-5617, U+561a, U+561c, U+561e, U+5621-5625, U+5627, U+5629, U+562b-5630, U+5636, U+5638-563a, U+563c, U+5640-5642, U+5649, U+564c-5650, U+5653-5655, U+5657-565b, U+5660, U+5663-5664, U+5666, U+566b, U+566f-5671, U+5673-567c, U+567e, U+5684-5687, U+568c, U+568e-5693, U+5695, U+5697, U+569b-569c, U+569e-569f, U+56a1-56a2, U+56a4-56a9, U+56ac-56af, U+56b1, U+56b4, U+56b6-56b8, U+56bf, U+56c1-56c3, U+56c9, U+56cd, U+56d1, U+56d4, U+56d6-56d9, U+56dd, U+56df, U+56e1, U+56e3-56e6, U+56e8-56ec, U+56ee-56ef, U+56f1-56f3, U+56f5, U+56f7-56f8; } /* [79] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.79.woff2) format("woff2"); unicode-range: U+550f, U+5511-5514, U+5516-5517, U+5519, U+551b, U+551d-551e, U+5520, U+5522-5523, U+5526-5527, U+552a-552c, U+5530, U+5532-5535, U+5537-5538, U+553b-5541, U+5543-5544, U+5547-5549, U+554b, U+554d, U+5550, U+5553, U+5555-5558, U+555b-555f, U+5567-5569, U+556b-5572, U+5574-5577, U+557b-557c, U+557e-557f, U+5581, U+5583, U+5585-5586, U+5588, U+558b-558c, U+558e-5591, U+5593, U+5599-559a, U+559f, U+55a5-55a6, U+55a8-55ac, U+55ae, U+55b0-55b3, U+55b6, U+55b9-55ba, U+55bc-55be, U+55c4, U+55c6-55c7, U+55c9, U+55cc-55d2, U+55d4-55db, U+55dd-55df, U+55e1, U+55e3-55e6, U+55ea-55ee, U+55f0-55f3; } /* [80] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.80.woff2) format("woff2"); unicode-range: U+53e7-53e9, U+53f1, U+53f4-53f5, U+53fa-5400, U+5402, U+5405-5407, U+540b, U+540f, U+5412, U+5414, U+5416, U+5418-541a, U+541d, U+5420-5423, U+5425, U+5429-542a, U+542d-542e, U+5431-5433, U+5436, U+543d, U+543f, U+5442-5443, U+5449, U+544b-544c, U+544e, U+5451-5454, U+5456, U+5459, U+545b-545c, U+5461, U+5463-5464, U+546a-5472, U+5474, U+5476-5478, U+547a, U+547e-5484, U+5486, U+548a, U+548d-548e, U+5490-5491, U+5494, U+5497-5499, U+549b, U+549d, U+54a1-54a7, U+54a9, U+54ab, U+54ad, U+54b4-54b5, U+54b9, U+54bb, U+54be-54bf, U+54c2-54c3, U+54c9-54cc, U+54cf-54d0, U+54d3, U+54d5-54d6, U+54d9-54da, U+54dc-54de, U+54e2, U+54e7, U+54f3-54f4, U+54f8-54f9, U+54fd-54ff, U+5501, U+5504-5506, U+550c-550e; } /* [81] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.81.woff2) format("woff2"); unicode-range: U+5289, U+528b, U+528d, U+528f, U+5291-5293, U+529a, U+52a2, U+52a6-52a7, U+52ac-52ad, U+52af, U+52b4-52b5, U+52b9, U+52bb-52bc, U+52be, U+52c1, U+52c5, U+52ca, U+52cd, U+52d0, U+52d6-52d7, U+52d9, U+52db, U+52dd-52de, U+52e0, U+52e2-52e3, U+52e5, U+52e7-52f0, U+52f2-52f3, U+52f5-52f9, U+52fb-52fc, U+5302, U+5304, U+530b, U+530d, U+530f-5310, U+5315, U+531a, U+531c-531d, U+5321, U+5323, U+5326, U+532e-5331, U+5338, U+533c-533e, U+5344-5345, U+534b-534d, U+5350, U+5354, U+5358, U+535d-535f, U+5363, U+5368-5369, U+536c, U+536e-536f, U+5372, U+5379-537b, U+537d, U+538d-538e, U+5390, U+5393-5394, U+5396, U+539b-539d, U+53a0-53a1, U+53a3-53a5, U+53a9, U+53ad-53ae, U+53b0, U+53b2-53b3, U+53b5-53b8, U+53bc, U+53be, U+53c1, U+53c3-53c7, U+53ce-53cf, U+53d2-53d3, U+53d5, U+53da, U+53de-53df, U+53e1-53e2; } /* [82] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.82.woff2) format("woff2"); unicode-range: U+5104, U+5106-5107, U+5109-510b, U+510d, U+510f-5110, U+5113, U+5115, U+5117-5118, U+511a-511c, U+511e-511f, U+5121, U+5128, U+512b-512d, U+5131-5135, U+5137-5139, U+513c, U+5140, U+5142, U+5147, U+514c, U+514e-5150, U+5155-5158, U+5162, U+5169, U+5172, U+517f, U+5181-5184, U+5186-5187, U+518b, U+518f, U+5191, U+5195-5197, U+519a, U+51a2-51a3, U+51a6-51ab, U+51ad-51ae, U+51b1, U+51b4, U+51bc-51bd, U+51bf, U+51c3, U+51c7-51c8, U+51ca-51cb, U+51cd-51ce, U+51d4, U+51d6, U+51db-51dc, U+51e6, U+51e8-51eb, U+51f1, U+51f5, U+51fc, U+51ff, U+5202, U+5205, U+5208, U+520b, U+520d-520e, U+5215-5216, U+5228, U+522a, U+522c-522d, U+5233, U+523c-523d, U+523f-5240, U+5245, U+5247, U+5249, U+524b-524c, U+524e, U+5250, U+525b-525f, U+5261, U+5263-5264, U+5270, U+5273, U+5275, U+5277, U+527d, U+527f, U+5281-5285, U+5287; } /* [83] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.83.woff2) format("woff2"); unicode-range: U+4fd1, U+4fd3, U+4fda-4fdc, U+4fdf-4fe0, U+4fe2-4fe4, U+4fe6, U+4fe8, U+4feb-4fed, U+4ff3, U+4ff5-4ff6, U+4ff8, U+4ffe, U+5001, U+5005-5006, U+5009, U+500c, U+500f, U+5013-5018, U+501b-501e, U+5022-5025, U+5027-5028, U+502b-502e, U+5030, U+5033-5034, U+5036-5039, U+503b, U+5041-5043, U+5045-5046, U+5048-504a, U+504c-504e, U+5051, U+5053, U+5055-5057, U+505b, U+505e, U+5060, U+5062-5063, U+5067, U+506a, U+506c, U+5070-5072, U+5074-5075, U+5078, U+507b, U+507d-507e, U+5080, U+5088-5089, U+5091-5092, U+5095, U+5097-509e, U+50a2-50a3, U+50a5-50a7, U+50a9, U+50ad, U+50b3, U+50b5, U+50b7, U+50ba, U+50be, U+50c4-50c5, U+50c7, U+50ca, U+50cd, U+50d1, U+50d5-50d6, U+50da, U+50de, U+50e5-50e6, U+50ec-50ee, U+50f0-50f1, U+50f3, U+50f9-50fb, U+50fe-5102; } /* [84] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.84.woff2) format("woff2"); unicode-range: U+4ea3, U+4ea5, U+4eb0-4eb1, U+4eb3-4eb6, U+4eb8-4eb9, U+4ebb-4ebe, U+4ec2-4ec4, U+4ec8-4ec9, U+4ecc, U+4ecf-4ed0, U+4ed2, U+4eda-4edb, U+4edd-4ee1, U+4ee6-4ee9, U+4eeb, U+4eee-4eef, U+4ef3-4ef5, U+4ef8-4efa, U+4efc, U+4f00, U+4f03-4f05, U+4f08-4f09, U+4f0b, U+4f0e, U+4f12-4f13, U+4f15, U+4f1b, U+4f1d, U+4f21-4f22, U+4f25, U+4f27-4f29, U+4f2b-4f2e, U+4f31-4f33, U+4f36-4f37, U+4f39, U+4f3e, U+4f40-4f41, U+4f43, U+4f47-4f49, U+4f54, U+4f57-4f58, U+4f5d-4f5e, U+4f61-4f62, U+4f64-4f65, U+4f67, U+4f6a, U+4f6e-4f6f, U+4f72, U+4f74-4f7e, U+4f80-4f82, U+4f84, U+4f89-4f8a, U+4f8e-4f98, U+4f9e, U+4fa1, U+4fa5, U+4fa9-4faa, U+4fac, U+4fb3, U+4fb6-4fb8, U+4fbd, U+4fc2, U+4fc5-4fc6, U+4fcd-4fce, U+4fd0; } /* [85] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.85.woff2) format("woff2"); unicode-range: U+3129, U+3131, U+3134, U+3137, U+3139, U+3141-3142, U+3145, U+3147-3148, U+314b, U+314d-314e, U+315c, U+3160-3161, U+3163-3164, U+3186, U+318d, U+3192, U+3196-3198, U+319e-319f, U+3220-3229, U+3231, U+3268, U+3297, U+3299, U+32a3, U+338e-338f, U+3395, U+339c-339e, U+33c4, U+33d1-33d2, U+33d5, U+3434, U+34dc, U+34ee, U+353e, U+355d, U+3566, U+3575, U+3592, U+35a0-35a1, U+35ad, U+35ce, U+36a2, U+36ab, U+38a8, U+3dab, U+3de7, U+3deb, U+3e1a, U+3f1b, U+3f6d, U+4495, U+4723, U+48fa, U+4ca3, U+4db6-4dbf, U+4e02, U+4e04-4e06, U+4e0c, U+4e0f, U+4e15, U+4e17, U+4e1f-4e21, U+4e26, U+4e29, U+4e2c, U+4e2f, U+4e31, U+4e35, U+4e37, U+4e3c, U+4e3f-4e42, U+4e44, U+4e46-4e47, U+4e57, U+4e5a-4e5c, U+4e64-4e65, U+4e67, U+4e69, U+4e6d, U+4e78, U+4e7f-4e82, U+4e85, U+4e87, U+4e8a, U+4e8d, U+4e93, U+4e96, U+4e98-4e99, U+4e9c, U+4e9e-4ea0, U+4ea2; } /* [86] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.86.woff2) format("woff2"); unicode-range: U+279f-27a2, U+27a4-27a5, U+27a8, U+27b0, U+27b2-27b3, U+27b9, U+27e8-27e9, U+27f6, U+2800, U+28ec, U+2913, U+2921-2922, U+2934-2935, U+2a2f, U+2b05-2b07, U+2b50, U+2b55, U+2bc5-2bc6, U+2e1c-2e1d, U+2ebb, U+2f00, U+2f08, U+2f24, U+2f2d, U+2f2f-2f30, U+2f3c, U+2f45, U+2f63-2f64, U+2f74, U+2f83, U+2f8f, U+2fbc, U+3003, U+3005-3007, U+3012-3013, U+301c-301e, U+3021, U+3023-3024, U+3030, U+3034-3035, U+3041, U+3043, U+3045, U+3047, U+3049, U+3056, U+3058, U+305c, U+305e, U+3062, U+306c, U+3074, U+3077, U+307a, U+307c-307d, U+3080, U+308e, U+3090-3091, U+3099-309b, U+309d-309e, U+30a5, U+30bc, U+30be, U+30c2, U+30c5, U+30cc, U+30d8, U+30e2, U+30e8, U+30ee, U+30f0-30f2, U+30f4-30f6, U+30fd-30fe, U+3105-3126, U+3128; } /* [87] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.87.woff2) format("woff2"); unicode-range: U+2651-2655, U+2658, U+265a-265b, U+265d-265e, U+2660-266d, U+266f, U+267b, U+2688, U+2693-2696, U+2698-2699, U+269c, U+26a0-26a1, U+26a4, U+26aa-26ab, U+26bd-26be, U+26c4-26c5, U+26d4, U+26e9, U+26f0-26f1, U+26f3, U+26f5, U+26fd, U+2702, U+2704-2706, U+2708-270f, U+2712-2718, U+271a-271b, U+271d, U+271f, U+2721, U+2724-2730, U+2732-2734, U+273a, U+273d-2744, U+2747-2749, U+274c, U+274e-274f, U+2753-2757, U+275b, U+275d-275e, U+2763, U+2765-2767, U+276e-276f, U+2776-277e, U+2780-2782, U+278a-278c, U+278e, U+2794-2796, U+279c; } /* [88] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.88.woff2) format("woff2"); unicode-range: U+2550-2551, U+2554, U+2557, U+255a-255b, U+255d, U+255f-2560, U+2562-2563, U+2565-2567, U+2569-256a, U+256c-2572, U+2579, U+2580-2595, U+25a1, U+25a3, U+25a9-25ad, U+25b0, U+25b3-25bb, U+25bd-25c2, U+25c4, U+25c8-25cb, U+25cd, U+25d0-25d1, U+25d4-25d5, U+25d8, U+25dc-25e6, U+25ea-25eb, U+25ef, U+25fe, U+2600-2604, U+2609, U+260e-260f, U+2611, U+2614-2615, U+2618, U+261a-2620, U+2622-2623, U+262a, U+262d-2630, U+2639-2640, U+2642, U+2648-2650; } /* [89] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.89.woff2) format("woff2"); unicode-range: U+23f0, U+23f3, U+2445, U+2449, U+2465-2471, U+2474-249b, U+24b8, U+24c2, U+24c7, U+24c9, U+24d0, U+24d2, U+24d4, U+24d8, U+24dd-24de, U+24e3, U+24e6, U+24e8, U+2500-2509, U+250b-2526, U+2528-2534, U+2536-2537, U+253b-2548, U+254a-254b; } /* [90] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.90.woff2) format("woff2"); unicode-range: U+207c-2083, U+208c-208e, U+2092, U+20a6, U+20a8-20ad, U+20af, U+20b1, U+20b4-20b5, U+20b8-20ba, U+20bd, U+20db, U+20dd, U+20e0, U+20e3, U+2105, U+2109, U+2113, U+2116-2117, U+2120-2121, U+2126, U+212b, U+2133, U+2139, U+2194, U+2196-2199, U+21a0, U+21a9-21aa, U+21af, U+21b3, U+21b5, U+21ba-21bb, U+21c4, U+21ca, U+21cc, U+21d0-21d4, U+21e1, U+21e6-21e9, U+2200, U+2202, U+2205-2208, U+220f, U+2211-2212, U+2215, U+2217-2219, U+221d-2220, U+2223, U+2225, U+2227-222b, U+222e, U+2234-2237, U+223c-223d, U+2248, U+224c, U+2252, U+2256, U+2260-2261, U+2266-2267, U+226a-226b, U+226e-226f, U+2282-2283, U+2295, U+2297, U+2299, U+22a5, U+22b0-22b1, U+22b9, U+22bf, U+22c5-22c6, U+22ef, U+2304, U+2307, U+230b, U+2312-2314, U+2318, U+231a-231b, U+2323, U+239b, U+239d-239e, U+23a0, U+23e9; } /* [91] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.91.woff2) format("woff2"); unicode-range: U+1d34-1d35, U+1d38-1d3a, U+1d3c, U+1d3f-1d40, U+1d49, U+1d4e-1d4f, U+1d52, U+1d55, U+1d5b, U+1d5e, U+1d9c, U+1da0, U+1dc4-1dc5, U+1e69, U+1e73, U+1ea0-1ea9, U+1eab-1ead, U+1eaf, U+1eb1, U+1eb3, U+1eb5, U+1eb7, U+1eb9, U+1ebb, U+1ebd-1ebe, U+1ec0-1ec3, U+1ec5-1ec6, U+1ec9-1ecd, U+1ecf-1ed3, U+1ed5, U+1ed7-1edf, U+1ee1, U+1ee3, U+1ee5-1eeb, U+1eed, U+1eef-1ef1, U+1ef3, U+1ef7, U+1ef9, U+1f62, U+1f7b, U+2001-2002, U+2004-2006, U+2009-200a, U+200c-2012, U+2015-2016, U+201a, U+201e-2021, U+2023, U+2025, U+2028, U+202a-202d, U+202f-2030, U+2032-2033, U+2035, U+2038, U+203c, U+203e-203f, U+2043-2044, U+2049, U+204d-204e, U+2060-2061, U+2070, U+2074-2078, U+207a-207b; } /* [97] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.97.woff2) format("woff2"); unicode-range: U+2ae-2b3, U+2b5-2bf, U+2c2-2c3, U+2c6-2d1, U+2d8-2da, U+2dc, U+2e1-2e3, U+2e5, U+2eb, U+2ee-2f0, U+2f2-2f7, U+2f9-2ff, U+302-30d, U+311, U+31b, U+321-325, U+327-329, U+32b-32c, U+32e-32f, U+331-339, U+33c-33d, U+33f, U+348, U+352, U+35c, U+35e-35f, U+361, U+363, U+368, U+36c, U+36f, U+530-540, U+55d-55e, U+561, U+563, U+565, U+56b, U+56e-579; } /* [98] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.98.woff2) format("woff2"); unicode-range: U+176-17f, U+192, U+194, U+19a-19b, U+19d, U+1a0-1a1, U+1a3-1a4, U+1aa, U+1ac-1ad, U+1af-1bf, U+1d2, U+1d4, U+1d6, U+1d8, U+1da, U+1dc, U+1e3, U+1e7, U+1e9, U+1ee, U+1f0-1f1, U+1f3, U+1f5-1ff, U+219-21b, U+221, U+223-226, U+228, U+22b, U+22f, U+231, U+234-237, U+23a-23b, U+23d, U+250-252, U+254-255, U+259-25e, U+261-263, U+265, U+268, U+26a-26b, U+26f-277, U+279, U+27b-280, U+282-283, U+285, U+28a, U+28c, U+28f, U+292, U+294-296, U+298-29a, U+29c, U+29f, U+2a1-2a4, U+2a6-2a7, U+2a9, U+2ab; } /* [99] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.99.woff2) format("woff2"); unicode-range: U+a1-a4, U+a6-a8, U+aa, U+ac, U+af, U+b1, U+b3-b6, U+b8-ba, U+bc-d6, U+d8-de, U+e6, U+eb, U+ee-f0, U+f5, U+f7-f8, U+fb, U+fd-100, U+102, U+104-107, U+10d, U+10f-112, U+115, U+117, U+119, U+11b, U+11e-11f, U+121, U+123, U+125-127, U+129-12a, U+12d, U+12f-13f, U+141-142, U+144, U+146, U+14b-14c, U+14f-153, U+158-15b, U+15e-160, U+163-165, U+168-16a, U+16d-175; } /* [100] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.100.woff2) format("woff2"); unicode-range: U+221a, U+2264, U+2464, U+25a0, U+3008, U+4e10, U+512a, U+5152, U+5201, U+5241, U+5340, U+5352, U+549a, U+54b2, U+54c6, U+54d7, U+54e1, U+5509, U+55c5, U+5618, U+5716, U+576f, U+5784, U+57a2, U+589f, U+5a20, U+5a25, U+5a29, U+5a34, U+5a7f, U+5ad6, U+5b09, U+5b5c, U+5bc7, U+5be6, U+5c27, U+5d2d, U+5dcd, U+5f1b, U+5f37, U+604d, U+6055, U+6073, U+60eb, U+61ff, U+62ce, U+62ed, U+6345, U+6390, U+63b0, U+63b7, U+64ae, U+64c2, U+64d2, U+6556, U+663c, U+667e, U+66d9, U+66f8, U+6756, U+6789, U+689d, U+68f1, U+695e, U+6975, U+6a1f, U+6b0a, U+6b61, U+6b87, U+6c5d, U+6c7e, U+6c92, U+6d31, U+6df9, U+6e0d, U+6e2d, U+6f31, U+6f3e, U+70b3, U+70bd, U+70ca, U+70e8, U+725f, U+733f, U+7396, U+739f, U+7459, U+74a7, U+75a1, U+75f0, U+76cf, U+76d4, U+7729, U+77aa, U+77b0, U+77e3, U+780c, U+78d5, U+7941, U+7977, U+797a, U+79c3, U+7a20, U+7a92, U+7b71, U+7bf1, U+7c9f, U+7eb6, U+7eca, U+7ef7, U+7f07, U+7f09, U+7f15, U+7f81, U+7fb9, U+8038, U+8098, U+80b4, U+8110, U+814b-814c, U+816e, U+818a, U+8205, U+8235, U+828b, U+82a5, U+82b7, U+82d4, U+82db, U+82df, U+8317, U+8338, U+8385-8386, U+83c1, U+83cf, U+8537, U+853b, U+854a, U+8715, U+8783, U+892a, U+8a71, U+8bb3, U+8d2e, U+8d58, U+8dbe, U+8f67, U+8fab, U+8fc4, U+8fe6, U+9023, U+9084, U+9091, U+916a, U+91c9, U+91dc, U+94b3, U+9502, U+9523, U+9551, U+956f, U+960e, U+962a, U+962e, U+9647, U+96f3, U+9739, U+97a0, U+97ed, U+983b, U+985e, U+988a, U+99ac, U+9a6f, U+9a87, U+9a8b, U+9ab7, U+9abc, U+9ac5, U+9e25, U+e608, U+e621, U+ff06, U+ff14-ff16; } /* [101] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.101.woff2) format("woff2"); unicode-range: U+161, U+926, U+928, U+939, U+93f-940, U+94d, U+e17, U+e22, U+e44, U+25c7, U+25ce, U+2764, U+3009, U+3016-3017, U+4e4d, U+4e53, U+4f5a, U+4f70, U+4fae, U+4fd8, U+4ffa, U+5011, U+501a, U+51c4, U+5225, U+547b, U+5495, U+54e8, U+54ee, U+5594, U+55d3, U+55dc, U+55fd, U+560e, U+565c, U+5662, U+5669, U+566c, U+56bc, U+5742, U+5824, U+5834, U+598a, U+5992, U+59a9, U+5a04, U+5ac9, U+5b75, U+5b7d, U+5bc5, U+5c49, U+5c90, U+5e1c, U+5e27, U+5e2b, U+5e37, U+5e90, U+618b, U+61f5, U+620a, U+620c, U+6273, U+62c7, U+62f7, U+6320, U+6342, U+6401-6402, U+6413, U+6512, U+655b, U+65a7, U+65f1, U+65f7, U+665f, U+6687, U+66a7, U+673d, U+67b8, U+6854, U+68d8, U+68fa, U+696d, U+6a02, U+6a0a, U+6a80, U+6b7c, U+6bd9, U+6c2e, U+6c76, U+6cf8, U+6d4a, U+6d85, U+6e24, U+6e32, U+6ec7, U+6f88, U+700f, U+701a, U+7078, U+707c, U+70ac, U+70c1, U+72e9, U+7409, U+7422, U+745a, U+7480, U+74a8, U+752b, U+7574, U+7656, U+7699, U+7737, U+785d, U+78be, U+79b9, U+7a3d, U+7a91, U+7a9f, U+7ae3, U+7b77, U+7c3f, U+7d1a, U+7d50, U+7d93, U+8042, U+808b, U+8236, U+82b8-82b9, U+82ef, U+8309, U+836b, U+83ef, U+8431, U+85c9, U+865e, U+868c, U+8759, U+8760, U+8845, U+89ba, U+8a2a, U+8aaa, U+8c41, U+8d2c, U+8d4e, U+8e66, U+8e6d, U+8eaf, U+902e, U+914b, U+916e, U+919b, U+949b, U+94a0, U+94b0, U+9541-9542, U+9556, U+95eb, U+95f5, U+964b, U+968b, U+96cc-96cd, U+96cf, U+9713, U+9890, U+98a8, U+9985, U+9992, U+9a6d, U+9a81, U+9a86, U+9ab8, U+9ca4, U+e606-e607, U+e60a, U+e60c, U+e60e, U+fe0f, U+ff02, U+ff1e; } /* [102] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.102.woff2) format("woff2"); unicode-range: U+10c, U+627-629, U+639, U+644, U+64a, U+203b, U+2265, U+2463, U+2573, U+25b2, U+3448-3449, U+4e1e, U+4e5e, U+4f3a, U+4f5f, U+4fea, U+5026, U+508d, U+516e, U+5189, U+5254, U+5288, U+52d8, U+52fa, U+5306, U+5308, U+5364, U+5384, U+53ed, U+543c, U+5450, U+5455, U+5466, U+54c4, U+5578, U+55a7, U+561f, U+5631, U+572d, U+575f, U+57ae, U+57e0, U+5830, U+594e, U+5984, U+5993, U+5bdd, U+5c0d, U+5c7f, U+5c82, U+5e62, U+5ed3, U+5f08, U+607a, U+60bc, U+625b, U+6292, U+62e2, U+6363, U+6467, U+6714, U+675e, U+6771, U+67a2, U+67ff, U+6805, U+68a7, U+68e0, U+6930, U+6986, U+69a8, U+69df, U+6a44, U+6a5f, U+6c13, U+6c1f, U+6c22, U+6c2f, U+6c40, U+6c81, U+6c9b, U+6ca5, U+6da4, U+6df3, U+6e85, U+6eba, U+6ed5, U+6f13, U+6f33, U+6f62, U+715e, U+72c4, U+73d1, U+7405, U+7487, U+7578, U+75a4, U+75eb, U+7693, U+7738, U+7741, U+776b, U+7792, U+77a7, U+77a9, U+77b3, U+788c, U+7984, U+79a7, U+79e4, U+7a1a, U+7a57, U+7aa6, U+7b0b, U+7b5d, U+7c27, U+7c7d, U+7caa, U+7cd9, U+7cef, U+7eda, U+7ede, U+7f24, U+803f, U+8046, U+80fa, U+81fb, U+8207, U+8258, U+8335, U+8339, U+8354, U+840e, U+85b0, U+85fb, U+8695, U+86aa, U+8717, U+8749, U+874c, U+8996, U+89bd, U+89c5, U+8bdb, U+8bf5, U+8c5a, U+8cec, U+8d3f, U+8d9f, U+8e44, U+8fed, U+9005, U+9019, U+9082, U+90af, U+90dd, U+90e1, U+90f8, U+916f, U+9176, U+949e, U+94a7, U+94c2, U+9525, U+9580, U+95dc, U+96e2, U+96fb, U+9704, U+9a7c, U+9a7f, U+9b41, U+9ca8, U+9cc4, U+9cde, U+9e92, U+9ede, U+9f9a, U+e60b, U+e610, U+ff10, U+ff13, U+ff3b, U+ff3d, U+f012b; } /* [103] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.103.woff2) format("woff2"); unicode-range: U+60, U+631, U+2606, U+3014-3015, U+309c, U+33a1, U+4e52, U+4ec6, U+4f86, U+4f8d, U+4fde, U+4fef, U+500b, U+502a, U+515c, U+518a, U+51a5, U+51f3, U+5243, U+52c9, U+52d5, U+53a2, U+53ee, U+54ce, U+54fa, U+54fc, U+5580, U+5587, U+563f, U+56da, U+5792, U+5815, U+5960, U+59d7, U+5b78, U+5b9b, U+5be1, U+5c4e, U+5c51, U+5c6f, U+5c9a, U+5cfb, U+5d16, U+5ed6, U+5f27, U+5f6a, U+609a, U+60df, U+6168, U+61c8, U+6236, U+62f1, U+62fd, U+631a, U+6328, U+632b, U+6346, U+638f, U+63a0, U+63c9, U+655e, U+6590, U+6615, U+6627, U+66ae, U+66e6, U+66f0, U+67da, U+67ec, U+6813, U+6816, U+6869, U+6893, U+68ad, U+68f5, U+6977, U+6984, U+69db, U+6b72, U+6bb7, U+6ce3, U+6cfb, U+6d47, U+6da1, U+6dc4, U+6e43, U+6eaf, U+6eff, U+6f8e, U+7011, U+7063, U+7076, U+7096, U+70ba, U+70db, U+70ef, U+7119-711a, U+7172, U+718f, U+7194, U+727a, U+72d9, U+72ed, U+7325, U+73ae, U+73ba, U+73c0, U+73fe, U+7410, U+7426, U+7455, U+7554, U+7576, U+75ae, U+75b9, U+762b, U+766b, U+7682, U+7750, U+7779, U+7784, U+77eb, U+77ee, U+78f7, U+79e9, U+7a79, U+7b1b, U+7b28, U+7bf7, U+7db2, U+7ec5, U+7eee, U+7f14, U+7f1a, U+7fe1, U+8087, U+809b, U+81b3, U+8231, U+830e, U+835f, U+83e9, U+849c, U+851a, U+868a, U+8718, U+874e, U+8822, U+8910, U+8944, U+8a3b, U+8bb6, U+8bbc, U+8e72, U+8f9c, U+900d, U+904b, U+904e, U+9063, U+90a2, U+90b9, U+9119, U+94f2, U+952f, U+9576-9577, U+9593, U+95f8, U+961c, U+969b, U+96a7, U+96c1, U+9716, U+9761, U+97ad, U+97e7, U+98a4, U+997a, U+9a73, U+9b44, U+9e3d, U+9ecf, U+9ed4, U+ff11-ff12, U+fffd; } /* [104] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.104.woff2) format("woff2"); unicode-range: U+2003, U+2193, U+2462, U+4e19, U+4e2b, U+4e36, U+4ea8, U+4ed1, U+4ed7, U+4f51, U+4f63, U+4f83, U+50e7, U+5112, U+5167, U+51a4, U+51b6, U+5239, U+5265, U+532a, U+5351, U+537f, U+5401, U+548f, U+5492, U+54af, U+54b3, U+54bd, U+54d1, U+54df, U+554f, U+5564, U+5598, U+5632, U+56a3, U+56e7, U+574e, U+575d-575e, U+57d4, U+584c, U+58e4, U+5937, U+5955, U+5a05, U+5a1f, U+5a49, U+5ac2, U+5c39, U+5c61, U+5d0e, U+5de9, U+5e9a, U+5eb8, U+5f0a, U+5f13, U+5f6c, U+5f8c, U+603c, U+608d, U+611b, U+6127, U+62a0, U+62d0, U+634f, U+635e, U+63fd, U+6577, U+658b, U+65bc, U+660a, U+6643, U+6656, U+6703, U+6760, U+67af, U+67c4, U+67e0, U+6817, U+68cd, U+690e, U+6960, U+69b4, U+6a71, U+6aac, U+6b67, U+6bb4, U+6c55, U+6c70, U+6c82, U+6ca6, U+6cb8, U+6cbe, U+6ede, U+6ee5, U+6f4d, U+6f84, U+6f9c, U+7115, U+7121, U+722a, U+7261, U+7272, U+7280, U+72f8, U+7504, U+754f, U+75d8, U+767c, U+76ef, U+778e, U+77bb, U+77f6, U+786b, U+78b1, U+7948, U+7985, U+79be, U+7a83, U+7a8d, U+7eac, U+7eef, U+7ef8, U+7efd, U+7f00, U+803d, U+8086, U+810a, U+8165, U+819d, U+81a8, U+8214, U+829c, U+831c, U+832b, U+8367, U+83e0, U+83f1, U+8403, U+846b, U+8475, U+84b2, U+8513, U+8574, U+85af, U+86d9, U+86db, U+8acb, U+8bbd, U+8be0-8be1, U+8c0e, U+8d29, U+8d50, U+8d63, U+8f7f, U+9032, U+9042, U+90b1, U+90b5, U+9165, U+9175, U+94a6, U+94c5, U+950c, U+9610, U+9631, U+9699, U+973e, U+978d, U+97ec, U+97f6, U+984c, U+987d, U+9882, U+9965, U+996a, U+9972, U+9a8f, U+9ad3, U+9ae6, U+9cb8, U+9edb, U+e600, U+e60f, U+e611, U+ff05, U+ff0b; } /* [105] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.105.woff2) format("woff2"); unicode-range: U+5e, U+2190, U+250a, U+25bc, U+25cf, U+4e56, U+4ea9, U+4f3d, U+4f6c, U+4f88, U+4fa8, U+4fcf, U+5029, U+5188, U+51f9, U+5203, U+524a, U+5256, U+529d, U+5375, U+53db, U+541f, U+5435, U+5457, U+548b, U+54c7, U+54d4, U+54e9, U+556a, U+5589, U+55bb, U+55e8, U+55ef, U+563b, U+566a, U+576a, U+58f9, U+598d, U+599e, U+59a8, U+5a9b, U+5ae3, U+5bb0, U+5bde, U+5c4c, U+5c60, U+5d1b, U+5deb, U+5df7, U+5e18, U+5f26, U+5f64, U+601c, U+6084, U+60e9, U+614c, U+6208, U+621a, U+6233, U+6254, U+62d8, U+62e6, U+62ef, U+6323, U+632a, U+633d, U+6361, U+6405, U+640f, U+6614, U+6642, U+6657, U+67a3, U+6808, U+683d, U+6850, U+6897, U+68b3, U+68b5, U+68d5, U+6a58, U+6b47, U+6b6a, U+6c28, U+6c90, U+6ca7, U+6cf5, U+6d51, U+6da9, U+6dc7, U+6dd1, U+6e0a, U+6e5b, U+6e9c, U+6f47, U+6f6d, U+70ad, U+70f9, U+710a, U+7130, U+71ac, U+745f, U+7476, U+7490, U+7529, U+7538, U+75d2, U+7696, U+76b1, U+76fc, U+777f, U+77dc, U+789f, U+795b, U+79bd, U+79c9, U+7a3b, U+7a46, U+7aa5, U+7ad6, U+7ca5, U+7cb9, U+7cdf, U+7d6e, U+7f06, U+7f38, U+7fa1, U+7fc1, U+8015, U+803b, U+80a2, U+80aa, U+8116, U+813e, U+82bd, U+8305, U+8328, U+8346, U+846c, U+8549, U+859b, U+8611, U+8680, U+87f9, U+884d, U+8877, U+888d, U+88d4, U+898b, U+8a79, U+8a93, U+8c05, U+8c0d, U+8c26, U+8d1e, U+8d31, U+8d81, U+8e22, U+8e81, U+8f90, U+8f96, U+90ca, U+916c, U+917f, U+9187, U+918b, U+9499, U+94a9, U+9524, U+9540, U+958b, U+9600, U+9640, U+96b6, U+96c7, U+96ef, U+98d9, U+9976, U+997f, U+9a74, U+9a84, U+9c8d, U+9e26, U+9e9f, U+ad6d, U+c5b4, U+d55c, U+ff0f; } /* [106] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.106.woff2) format("woff2"); unicode-range: U+b0, U+2191, U+2460-2461, U+25c6, U+300e-300f, U+4e1b, U+4e7e, U+4ed5, U+4ef2, U+4f10, U+4f1e, U+4f50, U+4fa6, U+4faf, U+5021, U+50f5, U+5179, U+5180, U+51d1, U+522e, U+52a3, U+52c3, U+52cb, U+5300, U+5319, U+5320, U+5349, U+5395, U+53d9, U+541e, U+5428, U+543e, U+54b1, U+54c0, U+54d2, U+570b, U+5858, U+58f6, U+5974, U+59a5, U+59e8, U+59ec, U+5a36, U+5a9a, U+5ab3, U+5b99, U+5baa, U+5ce1, U+5d14, U+5d4c, U+5dc5, U+5de2, U+5e99, U+5e9e, U+5f18, U+5f66, U+5f70, U+6070, U+60d5, U+60e7, U+6101, U+611a, U+61be, U+6241, U+6252, U+626f, U+6296, U+62bc, U+62cc, U+6380, U+63a9, U+644a, U+6454, U+64a9, U+64b8, U+6500, U+6572, U+65a5, U+65a9, U+65ec, U+660f, U+6749, U+6795, U+67ab, U+68da, U+6912, U+6bbf, U+6bef, U+6cab, U+6cca, U+6ccc, U+6cfc, U+6d3d, U+6d78, U+6dee, U+6e17, U+6e34, U+6e83, U+6ea2, U+6eb6, U+6f20, U+6fa1, U+707f, U+70d8, U+70eb, U+714c, U+714e, U+7235, U+7239, U+73ca, U+743c, U+745c, U+7624, U+763e, U+76f2, U+77db, U+77e9, U+780d, U+7838, U+7845, U+78ca, U+796d, U+7a84, U+7aed, U+7b3c, U+7eb2, U+7f05, U+7f20, U+7f34, U+7f62, U+7fc5, U+7fd8, U+7ff0, U+800d, U+8036, U+80ba, U+80be, U+80c0-80c1, U+8155, U+817a, U+8180, U+81e3, U+8206, U+8247, U+8270, U+8299, U+82ad, U+8304, U+8393, U+83b9, U+840d, U+8427, U+8469, U+8471, U+84c4, U+84ec, U+853d, U+8681-8682, U+8721, U+8854, U+88d5, U+88f9, U+8bc0, U+8c0a, U+8c29, U+8c2d, U+8d41, U+8dea, U+8eb2, U+8f9f, U+903b, U+903e, U+9102, U+9493, U+94a5, U+94f8, U+95f7, U+9706, U+9709, U+9774, U+98a0, U+9e64, U+9f9f, U+e603; } /* [107] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.107.woff2) format("woff2"); unicode-range: U+200b, U+2103, U+4e18, U+4e27-4e28, U+4e38, U+4e59, U+4e8f, U+4ead, U+4ec7, U+4fe9, U+503a, U+5085, U+5146, U+51af, U+51f8, U+52ab, U+5339, U+535c, U+5378, U+538c, U+5398, U+53f9, U+5415, U+5475, U+54aa, U+54ac, U+54b8, U+5582, U+5760, U+5764, U+57cb, U+5835, U+5885, U+5951, U+5983, U+59da, U+5a77, U+5b5d, U+5b5f, U+5bb5, U+5bc2, U+5be8, U+5bfa, U+5c2c, U+5c34, U+5c41, U+5c48, U+5c65, U+5cad, U+5e06, U+5e42, U+5ef7, U+5f17, U+5f25, U+5f6d, U+5f79, U+6028, U+6064, U+6068, U+606d, U+607c, U+6094, U+6109, U+6124, U+6247, U+626d, U+6291, U+629a, U+62ac, U+62b9, U+62fe, U+6324, U+6349, U+6367, U+6398, U+6495, U+64a4, U+64b0, U+64bc, U+64ce, U+658c, U+65ed, U+6602, U+6674, U+6691, U+66a8, U+674f, U+679a, U+67ef, U+67f4, U+680b, U+6876, U+68a8, U+6a59, U+6a61, U+6b20, U+6bc5, U+6d12, U+6d46, U+6d8c, U+6dc0, U+6e14, U+6e23, U+6f06, U+7164, U+716e, U+7199, U+71e5, U+72ac, U+742a, U+755c, U+75ab, U+75b2, U+75f4, U+7897, U+78b3, U+78c5, U+7978, U+79fd, U+7a74, U+7b4b, U+7b5b, U+7ece, U+7ed2, U+7ee3, U+7ef3, U+7f50, U+7f55, U+7f9e, U+7fe0, U+809d, U+8106, U+814a, U+8154, U+817b, U+818f, U+81c2, U+81ed, U+821f, U+82a6, U+82d1, U+8302, U+83c7, U+83ca, U+845b, U+848b, U+84c9, U+85e4, U+86ee, U+8700, U+8774, U+8881, U+8c1c, U+8c79, U+8d2a, U+8d3c, U+8eba, U+8f70, U+8fa9, U+8fb1, U+900a, U+9017, U+901d, U+9022, U+906e, U+946b, U+94dd, U+94ed, U+953b, U+95ef, U+95fa, U+95fd, U+96c0, U+971e, U+9753, U+9756, U+97e6, U+9881, U+9887, U+9b4f, U+9e2d, U+9f0e, U+e601-e602, U+e604-e605, U+ff5c; } /* [108] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.108.woff2) format("woff2"); unicode-range: U+24, U+4e08, U+4e43, U+4e4f, U+4ef0, U+4f2a, U+507f, U+50ac, U+50bb, U+5151, U+51bb, U+51f6, U+51fd, U+5272, U+52fe, U+5362, U+53c9, U+53d4, U+53e0, U+543b, U+54f2, U+5507, U+5524, U+558a, U+55b5, U+561b, U+56ca, U+5782, U+57c3, U+5893, U+5915, U+5949, U+5962, U+59ae, U+59dc, U+59fb, U+5bd3, U+5c38, U+5cb3, U+5d07, U+5d29, U+5de1, U+5dfe, U+5e15, U+5eca, U+5f2f, U+5f7c, U+5fcc, U+6021, U+609f, U+60f9, U+6108, U+6148, U+6155, U+6170, U+61d2, U+6251, U+629b, U+62ab, U+62e8, U+62f3, U+6321, U+6350, U+6566, U+659c, U+65e8, U+6635, U+6655, U+6670, U+66f9, U+6734, U+679d, U+6851, U+6905, U+6b49, U+6b96, U+6c1b, U+6c41, U+6c6a, U+6c83, U+6cf3, U+6d9b, U+6dcb, U+6e1d, U+6e20-6e21, U+6eaa, U+6ee4, U+6ee9, U+6f58, U+70e4, U+722c, U+7262, U+7267, U+72b9, U+72e0, U+72ee, U+72f1, U+7334, U+73ab, U+7433, U+7470, U+758f, U+75d5, U+764c, U+7686, U+76c6, U+76fe, U+7720, U+77e2, U+7802, U+7816, U+788d, U+7891, U+7a00, U+7a9d, U+7b52, U+7bad, U+7c98, U+7cca, U+7eba, U+7eea, U+7ef5, U+7f1d, U+7f69, U+806a, U+809a, U+80bf, U+80c3, U+81c0, U+820c, U+82ac, U+82af, U+82cd, U+82d7, U+838e, U+839e, U+8404, U+84b8, U+852c, U+8587, U+8650, U+8679, U+86c7, U+8702, U+87ba, U+886b-886c, U+8870, U+8c10, U+8c23, U+8c6b, U+8d3e, U+8d4b-8d4c, U+8d64, U+8d6b, U+8d74, U+8e29, U+8f69, U+8f74, U+8fb0, U+8fdf, U+901b, U+9038, U+9093, U+9171, U+9489, U+94ae, U+94c3, U+9508, U+9510, U+9601, U+9614, U+964c, U+9675, U+971c, U+97f5, U+9888, U+98d8, U+9971, U+9aa4, U+9e3f, U+9e45, U+9e4f, U+9e70, U+9f7f, U+e715; } /* [109] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.109.woff2) format("woff2"); unicode-range: U+a5, U+2192, U+2605, U+4e11, U+4e22, U+4e32, U+4f0d, U+4f0f, U+4f69, U+4ff1, U+50b2, U+5154, U+51dd, U+51f0, U+5211, U+5269, U+533f, U+5366-5367, U+5389, U+5413, U+5440, U+5446, U+5561, U+574a, U+5751, U+57ab, U+5806, U+5821, U+582a, U+58f3, U+5938, U+5948, U+5978, U+59d1, U+5a03, U+5a07, U+5ac1, U+5acc, U+5ae9, U+5bb4, U+5bc4, U+5c3f, U+5e3d, U+5e7d, U+5f92, U+5faa, U+5fe0, U+5ffd, U+6016, U+60a0, U+60dc, U+60e8, U+614e, U+6212, U+6284, U+62c6, U+62d3-62d4, U+63f4, U+642c, U+6478, U+6491-6492, U+64e6, U+6591, U+65a4, U+664b, U+6735, U+6746, U+67f1, U+67f3, U+6842, U+68af, U+68c9, U+68cb, U+6a31, U+6b3a, U+6bc1, U+6c0f, U+6c27, U+6c57, U+6cc4, U+6ce5, U+6d2a, U+6d66, U+6d69, U+6daf, U+6e58, U+6ecb, U+6ef4, U+707e, U+7092, U+70ab, U+71d5, U+7275, U+7384, U+73b2, U+7434, U+74e6, U+74f7, U+75bc, U+76c8, U+76d0, U+7709, U+77ac, U+7855, U+78a7, U+78c1, U+7a77, U+7b79, U+7c92, U+7cae, U+7cd5, U+7ea4, U+7eb5, U+7ebd, U+7f5a, U+7fd4, U+7ffc, U+8083, U+8096, U+80a0, U+80d6, U+80de, U+8102, U+8109, U+810f, U+8179, U+8292, U+82b3, U+8352, U+8361, U+83cc, U+841d, U+8461, U+8482, U+8521, U+857e, U+85aa, U+866b, U+8776, U+8896, U+889c, U+88f8, U+8a9e, U+8bc8, U+8bf8, U+8c0b, U+8c28, U+8d2b, U+8d2f, U+8d37, U+8d3a, U+8d54, U+8dc3, U+8dcc, U+8df5, U+8e0f, U+8e48, U+8f86, U+8f88, U+8f9e, U+8fc1, U+8fc8, U+8feb, U+9065, U+90a6, U+90aa, U+90bb, U+90c1, U+94dc, U+9521, U+9676, U+96d5, U+970d, U+9897, U+997c, U+9a70, U+9a76, U+9a9a, U+9ad4, U+9e23, U+9e7f, U+9f3b, U+e675, U+e6b9, U+ffe5; } /* [110] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.110.woff2) format("woff2"); unicode-range: U+300c-300d, U+4e54, U+4e58, U+4e95, U+4ec1, U+4f2f, U+4f38, U+4fa3, U+4fca, U+503e, U+5141, U+5144, U+517c, U+51cc, U+51ed, U+5242, U+52b2, U+52d2, U+52e4, U+540a, U+5439, U+5448, U+5496, U+54ed, U+5565, U+5761, U+5766, U+58ee, U+593a, U+594b, U+594f, U+5954, U+5996, U+59c6, U+59ff, U+5b64, U+5bff, U+5c18, U+5c1d, U+5c97, U+5ca9, U+5cb8, U+5e9f, U+5ec9, U+5f04, U+5f7b, U+5fa1, U+5fcd, U+6012, U+60a6, U+60ac, U+60b2, U+60ef, U+626e, U+6270, U+6276, U+62d6, U+62dc, U+6316, U+632f, U+633a, U+6355, U+63aa, U+6447, U+649e, U+64c5, U+654c, U+65c1, U+65cb, U+65e6, U+6606, U+6731, U+675c, U+67cf, U+67dc, U+6846, U+6b8b, U+6beb, U+6c61, U+6c88, U+6cbf, U+6cdb, U+6cea, U+6d45, U+6d53, U+6d74, U+6d82, U+6da8, U+6db5, U+6deb, U+6eda, U+6ee8, U+6f0f, U+706d, U+708e, U+70ae, U+70bc, U+70c2, U+70e6, U+7237-7238, U+72fc, U+730e, U+731b, U+739b, U+73bb, U+7483, U+74dc, U+74f6, U+7586, U+7626, U+775b, U+77ff, U+788e, U+78b0, U+7956, U+7965, U+79e6, U+7af9, U+7bee, U+7c97, U+7eb1, U+7eb7, U+7ed1, U+7ed5, U+7f6a, U+7f72, U+7fbd, U+8017, U+808c, U+80a9, U+80c6, U+80ce, U+8150, U+8170, U+819c, U+820d, U+8230, U+8239, U+827e, U+8377, U+8389, U+83b2, U+8428, U+8463, U+867e, U+88c2, U+88d9, U+8986, U+8bca, U+8bde, U+8c13, U+8c8c, U+8d21, U+8d24, U+8d56, U+8d60, U+8d8b, U+8db4, U+8e2a, U+8f68, U+8f89, U+8f9b, U+8fa8, U+8fbd, U+9003, U+90ce, U+90ed, U+9189, U+94bb, U+9505, U+95f9, U+963b, U+9655, U+966a, U+9677, U+96fe, U+9896, U+99a8, U+9a71, U+9a82, U+9a91, U+9b45, U+9ece, U+9f20, U+feff, U+ff0d; } /* [111] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.111.woff2) format("woff2"); unicode-range: U+4e4c, U+4e88, U+4ea1, U+4ea6, U+4ed3-4ed4, U+4eff, U+4f30, U+4fa7, U+4fc4, U+4fd7, U+500d, U+504f, U+5076-5077, U+517d, U+5192, U+51c9, U+51ef, U+5238, U+5251, U+526a, U+52c7, U+52df, U+52ff, U+53a6, U+53a8, U+53ec, U+5410, U+559d, U+55b7, U+5634, U+573e, U+5783, U+585e, U+586b, U+58a8, U+5999, U+59d3, U+5a1c, U+5a46, U+5b54-5b55, U+5b85, U+5b8b, U+5b8f, U+5bbf, U+5bd2, U+5c16, U+5c24, U+5e05, U+5e45, U+5e7c, U+5e84, U+5f03, U+5f1f, U+5f31, U+5f84, U+5f90, U+5fbd, U+5fc6, U+5fd9, U+5fe7, U+6052, U+6062, U+6089, U+60a3, U+60d1, U+6167, U+622a, U+6234, U+624e, U+6269, U+626c, U+62b5, U+62d2, U+6325, U+63e1, U+643a, U+6446, U+6562, U+656c, U+65e2, U+65fa, U+660c, U+6628, U+6652, U+6668, U+6676, U+66fc, U+66ff, U+6717, U+676d, U+67aa, U+67d4, U+6843, U+6881, U+68d2, U+695a, U+69fd, U+6a2a, U+6b8a, U+6c60, U+6c64, U+6c9f, U+6caa, U+6cc9, U+6ce1, U+6cfd, U+6d1b, U+6d1e, U+6d6e, U+6de1, U+6e10, U+6e7f, U+6f5c, U+704c, U+7070, U+7089, U+70b8, U+718a, U+71c3, U+723d, U+732a, U+73cd, U+7518, U+756a, U+75af, U+75be, U+75c7, U+76d2, U+76d7, U+7763, U+78e8, U+795d, U+79df, U+7c4d, U+7d2f, U+7ee9, U+7f13, U+7f8a, U+8000, U+8010, U+80af, U+80f6, U+80f8, U+8212, U+8273, U+82f9, U+83ab, U+83b1, U+83f2, U+8584, U+871c, U+8861, U+888b, U+88c1, U+88e4, U+8bd1, U+8bf1, U+8c31, U+8d5a, U+8d75-8d76, U+8de8, U+8f85, U+8fa3, U+8fc5, U+9006, U+903c, U+904d, U+9075, U+9178, U+9274, U+950b, U+9526, U+95ea, U+9636, U+9686, U+978b, U+987f, U+9a7e, U+9b42, U+9e1f, U+9ea6, U+9f13, U+9f84, U+ff5e; } /* [112] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.112.woff2) format("woff2"); unicode-range: U+23, U+3d, U+4e01, U+4e39, U+4e73, U+4ecd, U+4ed9, U+4eea, U+4f0a, U+4f1f, U+4f5b, U+4fa0, U+4fc3, U+501f, U+50a8, U+515a, U+5175, U+51a0, U+51c0, U+51e1, U+51e4, U+5200, U+520a, U+5224, U+523a, U+52aa, U+52b1, U+52b3, U+5348, U+5353, U+5360, U+5371, U+5377, U+539a, U+541b, U+5434, U+547c, U+54e6, U+5510, U+5531, U+5609, U+56f0, U+56fa, U+5733, U+574f, U+5851, U+5854, U+5899, U+58c1, U+592e, U+5939, U+5976, U+5986, U+59bb, U+5a18, U+5a74, U+5b59, U+5b87, U+5b97, U+5ba0, U+5bab, U+5bbd-5bbe, U+5bf8, U+5c0a, U+5c3a, U+5c4a, U+5e16, U+5e1d, U+5e2d, U+5e8a, U+6015, U+602a, U+6050, U+6069, U+6162, U+61c2, U+6293, U+6297, U+62b1, U+62bd, U+62df, U+62fc, U+6302, U+635f, U+638c, U+63ed, U+6458, U+6469, U+6563, U+6620, U+6653, U+6696-6697, U+66dd, U+675f, U+676f-6770, U+67d0, U+67d3, U+684c, U+6865, U+6885, U+68b0, U+68ee, U+690d, U+6b23, U+6b32, U+6bd5, U+6c89, U+6d01, U+6d25, U+6d89, U+6da6, U+6db2, U+6df7, U+6ed1, U+6f02, U+70c8, U+70df, U+70e7, U+7126, U+7236, U+7259, U+731c, U+745e, U+74e3, U+751a, U+751c, U+7532, U+7545, U+75db, U+7761, U+7a0d, U+7b51, U+7ca4, U+7cd6, U+7d2b, U+7ea0, U+7eb9, U+7ed8, U+7f18, U+7f29, U+8033, U+804a, U+80a4-80a5, U+80e1, U+817f, U+829d, U+82e6, U+8336, U+840c, U+8499, U+864e, U+8651, U+865a, U+88ad, U+89e6, U+8bd7, U+8bfa, U+8c37, U+8d25, U+8d38, U+8ddd, U+8fea, U+9010, U+9012, U+906d, U+907f-9080, U+90d1, U+9177, U+91ca, U+94fa, U+9501, U+9634-9635, U+9694, U+9707, U+9738, U+9769, U+9a7b, U+9a97, U+9aa8, U+9b3c, U+9c81, U+9ed8; } /* [113] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.113.woff2) format("woff2"); unicode-range: U+26, U+3c, U+d7, U+4e4e, U+4e61, U+4e71, U+4ebf, U+4f26, U+5012, U+51ac, U+51b0, U+51b2, U+51b7, U+5218, U+521a, U+5220, U+5237, U+523b, U+526f, U+5385, U+53bf, U+53e5, U+53eb, U+53f3, U+53f6, U+5409, U+5438, U+54c8, U+54e5, U+552f, U+5584, U+5706, U+5723, U+5750, U+575a, U+5987-5988, U+59b9, U+59d0, U+59d4, U+5b88, U+5b9c, U+5bdf, U+5bfb, U+5c01, U+5c04, U+5c3e, U+5c4b, U+5c4f, U+5c9b, U+5cf0, U+5ddd, U+5de6, U+5de8, U+5e01, U+5e78, U+5e7b, U+5e9c, U+5ead, U+5ef6, U+5f39, U+5fd8, U+6000, U+6025, U+604b, U+6076, U+613f, U+6258, U+6263, U+6267, U+6298, U+62a2, U+62e5, U+62ec, U+6311, U+6377, U+6388-6389, U+63a2, U+63d2, U+641e, U+642d, U+654f, U+6551, U+6597, U+65cf, U+65d7, U+65e7, U+6682, U+66f2, U+671d, U+672b, U+6740, U+6751, U+6768, U+6811, U+6863, U+6982, U+6bd2, U+6cf0, U+6d0b, U+6d17, U+6d59, U+6dd8, U+6dfb, U+6e7e, U+6f6e, U+6fb3, U+706f, U+719f, U+72af, U+72d0, U+72d7, U+732b, U+732e, U+7389, U+73e0, U+7530, U+7687, U+76d6, U+76db, U+7840, U+786c, U+79cb, U+79d2, U+7a0e, U+7a33, U+7a3f, U+7a97, U+7ade-7adf, U+7b26, U+7e41, U+7ec3, U+7f3a, U+8089, U+80dc, U+811a, U+8131, U+8138, U+821e, U+8349, U+83dc, U+8457, U+867d, U+86cb, U+8a89, U+8ba8, U+8bad, U+8bef, U+8bfe, U+8c6a, U+8d1d, U+8d4f, U+8d62, U+8dd1, U+8df3, U+8f6e, U+8ff9, U+900f, U+9014, U+9057, U+9192, U+91ce, U+9488, U+94a2, U+9547, U+955c, U+95f2, U+9644, U+964d, U+96c4-96c5, U+96e8, U+96f6-96f7, U+9732, U+9759, U+9760, U+987a, U+989c, U+9910, U+996d-996e, U+9b54, U+9e21, U+9ebb, U+9f50; } /* [114] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.114.woff2) format("woff2"); unicode-range: U+7e, U+2026, U+4e03, U+4e25, U+4e30, U+4e34, U+4e45, U+4e5d, U+4e89, U+4eae, U+4ed8, U+4f11, U+4f19, U+4f24, U+4f34, U+4f59, U+4f73, U+4f9d, U+4fb5, U+5047, U+505c, U+5170, U+519c, U+51cf, U+5267, U+5356, U+5374, U+5382, U+538b, U+53e6, U+5426, U+542b, U+542f, U+5462, U+5473, U+554a, U+5566, U+5708, U+571f, U+5757, U+57df, U+57f9, U+5802, U+590f, U+591c, U+591f, U+592b, U+5965, U+5979, U+5a01, U+5a5a, U+5b63, U+5b69, U+5b81, U+5ba1, U+5ba3, U+5c3c, U+5c42, U+5c81, U+5de7, U+5dee, U+5e0c, U+5e10, U+5e55, U+5e86, U+5e8f, U+5ea7, U+5f02, U+5f52, U+5f81, U+5ff5, U+60ca, U+60e0, U+6279, U+62c5, U+62ff, U+63cf, U+6444, U+64cd, U+653b, U+65bd, U+65e9, U+665a, U+66b4, U+66fe, U+6728, U+6742, U+677e, U+67b6, U+680f, U+68a6, U+68c0, U+699c, U+6b4c, U+6b66, U+6b7b, U+6bcd, U+6bdb, U+6c38, U+6c47, U+6c49, U+6cb3, U+6cb9, U+6ce2, U+6d32, U+6d3e, U+6d4f, U+6e56, U+6fc0, U+7075, U+7206, U+725b, U+72c2, U+73ed, U+7565, U+7591, U+7597, U+75c5, U+76ae, U+76d1, U+76df, U+7834, U+7968, U+7981, U+79c0, U+7a7f, U+7a81, U+7ae5, U+7b14, U+7c89, U+7d27, U+7eaf, U+7eb3, U+7eb8, U+7ec7, U+7ee7, U+7eff, U+7f57, U+7ffb, U+805a, U+80a1, U+822c, U+82cf, U+82e5, U+8363, U+836f, U+84dd, U+878d, U+8840, U+8857, U+8863, U+8865, U+8b66, U+8bb2, U+8bda, U+8c01, U+8c08, U+8c46, U+8d1f, U+8d35, U+8d5b, U+8d5e, U+8da3, U+8ddf, U+8f93, U+8fdd, U+8ff0, U+8ff7, U+8ffd, U+9000, U+9047, U+9152, U+949f, U+94c1, U+94f6, U+9646, U+9648, U+9669, U+969c, U+96ea, U+97e9, U+987b, U+987e, U+989d, U+9970, U+9986, U+9c7c, U+9c9c; } /* [115] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.115.woff2) format("woff2"); unicode-range: U+25, U+4e14, U+4e1d, U+4e3d, U+4e49, U+4e60, U+4e9a, U+4eb2, U+4ec5, U+4efd, U+4f3c, U+4f4f, U+4f8b, U+4fbf, U+5019, U+5145, U+514b, U+516b, U+516d, U+5174, U+5178, U+517b, U+5199, U+519b, U+51b3, U+51b5, U+5207, U+5212, U+5219, U+521d, U+52bf, U+533b, U+5343, U+5347, U+534a, U+536b, U+5370, U+53e4, U+53f2, U+5403, U+542c, U+547d, U+54a8, U+54cd, U+54ea, U+552e, U+56f4, U+5747, U+575b, U+5883, U+589e, U+5931, U+5947, U+5956-5957, U+5a92, U+5b83, U+5ba4, U+5bb3, U+5bcc, U+5c14, U+5c1a, U+5c3d, U+5c40, U+5c45, U+5c5e, U+5df4, U+5e72, U+5e95, U+5f80, U+5f85, U+5fb7, U+5fd7, U+601d, U+626b, U+627f, U+62c9, U+62cd, U+6309, U+63a7, U+6545, U+65ad, U+65af, U+65c5, U+666e, U+667a, U+670b, U+671b, U+674e, U+677f, U+6781, U+6790, U+6797, U+6821, U+6838-6839, U+697c, U+6b27, U+6b62, U+6bb5, U+6c7d, U+6c99, U+6d4b, U+6d4e, U+6d6a, U+6e29, U+6e2f, U+6ee1, U+6f14, U+6f2b, U+72b6, U+72ec, U+7387, U+7533, U+753b, U+76ca, U+76d8, U+7701, U+773c, U+77ed, U+77f3, U+7814, U+793c, U+79bb, U+79c1, U+79d8, U+79ef, U+79fb, U+7a76, U+7b11, U+7b54, U+7b56, U+7b97, U+7bc7, U+7c73, U+7d20, U+7eaa, U+7ec8, U+7edd, U+7eed, U+7efc, U+7fa4, U+804c, U+8058, U+80cc, U+8111, U+817e, U+826f, U+8303, U+843d, U+89c9, U+89d2, U+8ba2, U+8bbf, U+8bc9, U+8bcd, U+8be6, U+8c22, U+8c61, U+8d22, U+8d26-8d27, U+8d8a, U+8f6f, U+8f7b, U+8f83, U+8f91, U+8fb9, U+8fd4, U+8fdc, U+9002, U+94b1, U+9519, U+95ed, U+961f, U+9632-9633, U+963f, U+968f-9690, U+96be, U+9876, U+9884, U+98de, U+9988, U+9999, U+9ec4, U+ff1b; } /* [116] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.116.woff2) format("woff2"); unicode-range: U+2b, U+40, U+3000, U+300a-300b, U+4e16, U+4e66, U+4e70, U+4e91-4e92, U+4e94, U+4e9b, U+4ec0, U+4eca, U+4f01, U+4f17-4f18, U+4f46, U+4f4e, U+4f9b, U+4fee, U+503c, U+5065, U+50cf, U+513f, U+5148, U+518d, U+51c6, U+51e0, U+5217, U+529e-529f, U+5341, U+534f, U+5361, U+5386, U+53c2, U+53c8, U+53cc, U+53d7-53d8, U+53ea, U+5404, U+5411, U+5417, U+5427, U+5468, U+559c, U+5668, U+56e0, U+56e2, U+56ed, U+5740, U+57fa, U+58eb, U+5904, U+592a, U+59cb, U+5a31, U+5b58, U+5b9d, U+5bc6, U+5c71, U+5dde, U+5df1, U+5e08, U+5e26, U+5e2e, U+5e93, U+5e97, U+5eb7, U+5f15, U+5f20, U+5f3a, U+5f62, U+5f69, U+5f88, U+5f8b, U+5fc5, U+600e, U+620f, U+6218, U+623f, U+627e, U+628a, U+62a4, U+62db, U+62e9, U+6307, U+6362, U+636e, U+64ad, U+6539, U+653f, U+6548, U+6574, U+6613, U+6625, U+663e, U+666f, U+672a, U+6750, U+6784, U+6a21, U+6b3e, U+6b65, U+6bcf, U+6c11, U+6c5f, U+6df1, U+706b, U+7167, U+724c, U+738b, U+73a9, U+73af, U+7403, U+7537, U+754c, U+7559, U+767d, U+7740, U+786e, U+795e, U+798f, U+79f0, U+7aef, U+7b7e, U+7bb1, U+7ea2, U+7ea6, U+7ec4, U+7ec6, U+7ecd, U+7edc, U+7ef4, U+8003, U+80b2, U+81f3-81f4, U+822a, U+827a, U+82f1, U+83b7, U+8425, U+89c2, U+89c8, U+8ba9, U+8bb8, U+8bc6, U+8bd5, U+8be2, U+8be5, U+8bed, U+8c03, U+8d23, U+8d2d, U+8d34, U+8d70, U+8db3, U+8fbe, U+8fce, U+8fd1, U+8fde, U+9001, U+901f-9020, U+90a3, U+914d, U+91c7, U+94fe, U+9500, U+952e, U+9605, U+9645, U+9662, U+9664, U+9700, U+9752, U+975e, U+97f3, U+9879, U+9886, U+98df, U+9a6c, U+9a8c, U+9ed1, U+9f99; } /* [117] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.117.woff2) format("woff2"); unicode-range: U+4e, U+201c-201d, U+3010-3011, U+4e07, U+4e1c, U+4e24, U+4e3e, U+4e48, U+4e50, U+4e5f, U+4e8b-4e8c, U+4ea4, U+4eab-4eac, U+4ecb, U+4ece, U+4ed6, U+4ee3, U+4ef6-4ef7, U+4efb, U+4f20, U+4f55, U+4f7f, U+4fdd, U+505a, U+5143, U+5149, U+514d, U+5171, U+5177, U+518c, U+51fb, U+521b, U+5229, U+522b, U+52a9, U+5305, U+5317, U+534e, U+5355, U+5357, U+535a, U+5373, U+539f, U+53bb, U+53ca, U+53cd, U+53d6, U+53e3, U+53f0, U+5458, U+5546, U+56db, U+573a, U+578b, U+57ce, U+58f0, U+590d, U+5934, U+5973, U+5b57, U+5b8c, U+5b98, U+5bb9, U+5bfc, U+5c06, U+5c11, U+5c31, U+5c55, U+5df2, U+5e03, U+5e38, U+5e76, U+5e94, U+5efa, U+5f71, U+5f97, U+5feb, U+6001, U+603b, U+60f3, U+611f, U+6216, U+624d, U+6253, U+6295, U+6301, U+6392, U+641c, U+652f, U+653e, U+6559, U+6599, U+661f, U+671f, U+672f, U+6761, U+67e5, U+6807, U+6837, U+683c, U+6848, U+6b22, U+6b64, U+6bd4, U+6c14, U+6c34, U+6c42, U+6ca1, U+6d41, U+6d77, U+6d88, U+6e05, U+6e38, U+6e90, U+7136, U+7231, U+7531, U+767e, U+76ee, U+76f4, U+771f, U+7801, U+793a, U+79cd, U+7a0b, U+7a7a, U+7acb, U+7ae0, U+7b2c, U+7b80, U+7ba1, U+7cbe, U+7d22, U+7ea7, U+7ed3, U+7ed9, U+7edf, U+7f16, U+7f6e, U+8001, U+800c, U+8272, U+8282, U+82b1, U+8350, U+88ab, U+88c5, U+897f, U+89c1, U+89c4, U+89e3, U+8a00, U+8ba1, U+8ba4, U+8bae-8bb0, U+8bbe, U+8bc1, U+8bc4, U+8bfb, U+8d28, U+8d39, U+8d77, U+8d85, U+8def, U+8eab, U+8f66, U+8f6c, U+8f7d, U+8fd0, U+9009, U+90ae, U+90fd, U+91cc-91cd, U+91cf, U+95fb, U+9650, U+96c6, U+9891, U+98ce, U+ff1f; } /* [118] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.118.woff2) format("woff2"); unicode-range: U+d, U+3e, U+5f, U+7c, U+a0, U+a9, U+4e09-4e0b, U+4e0d-4e0e, U+4e13, U+4e1a, U+4e2a, U+4e3a-4e3b, U+4e4b, U+4e86, U+4e8e, U+4ea7, U+4eba, U+4ee4-4ee5, U+4eec, U+4f1a, U+4f4d, U+4f53, U+4f5c, U+4f60, U+4fe1, U+5165, U+5168, U+516c, U+5173, U+5176, U+5185, U+51fa, U+5206, U+5230, U+5236, U+524d, U+529b, U+52a0-52a1, U+52a8, U+5316, U+533a, U+53cb, U+53d1, U+53ef, U+53f7-53f8, U+5408, U+540c-540e, U+544a, U+548c, U+54c1, U+56de, U+56fd-56fe, U+5728, U+5730, U+5907, U+5916, U+591a, U+5927, U+5929, U+597d, U+5982, U+5b50, U+5b66, U+5b89, U+5b9a, U+5b9e, U+5ba2, U+5bb6, U+5bf9, U+5c0f, U+5de5, U+5e02, U+5e73-5e74, U+5e7f, U+5ea6, U+5f00, U+5f0f, U+5f53, U+5f55, U+5fae, U+5fc3, U+6027, U+606f, U+60a8, U+60c5, U+610f, U+6210-6211, U+6237, U+6240, U+624b, U+6280, U+62a5, U+63a5, U+63a8, U+63d0, U+6536, U+6570, U+6587, U+65b9, U+65e0, U+65f6, U+660e, U+662d, U+662f, U+66f4, U+6700, U+670d, U+672c, U+673a, U+6743, U+6765, U+679c, U+682a, U+6b21, U+6b63, U+6cbb, U+6cd5, U+6ce8, U+6d3b, U+70ed, U+7247-7248, U+7269, U+7279, U+73b0, U+7406, U+751f, U+7528, U+7535, U+767b, U+76f8, U+770b, U+77e5, U+793e, U+79d1, U+7ad9, U+7b49, U+7c7b, U+7cfb, U+7ebf, U+7ecf, U+7f8e, U+8005, U+8054, U+80fd, U+81ea, U+85cf, U+884c, U+8868, U+8981, U+89c6, U+8bba, U+8bdd, U+8bf4, U+8bf7, U+8d44, U+8fc7, U+8fd8-8fd9, U+8fdb, U+901a, U+9053, U+90e8, U+91d1, U+957f, U+95e8, U+95ee, U+95f4, U+9762, U+9875, U+9898, U+9996, U+9ad8, U+ff01, U+ff08-ff09; } /* [119] */ @font-face { font-family: "Noto Serif SC"; font-style: normal; font-weight: 200 900; font-display: swap; src: url(https://fonts.gstatic.com/s/notoserifsc/v33/H4chBXePl9DZ0Xe7gG9cyOj7oqOcaThrVMcaeccjhXXDsOyAEEmuIi6j7j64sLjgBtMI1z49XW4.119.woff2) format("woff2"); unicode-range: U+300-301, /* Combining grave and acute */ U+2013-2014, /* En/em dash */ U+2022, U+2027, /* Bullet, interpunct */ U+2039-203A, /* ‹ › */ U+2122, /* Trademark */ U+3001-3002, /* 、。 */ U+3042, U+3044, U+3046, U+3048, U+304A-3055, U+3057, U+3059-305B, U+305D, U+305F-3061, U+3063-306B, U+306D-3073, U+3075-3076, U+3078-3079, U+307B, U+307E-307F, U+3081-308D, U+308F, U+3092-3093, /* Hiragana */ U+30A1-30A4, U+30A6-30BB, U+30BD, U+30BF-30C1, U+30C3-30C4, U+30C6-30CB, U+30CD-30D7, U+30D9-30E1, U+30E3-30E7, U+30E9-30ED, U+30EF, U+30F3, U+30FB-30FC, /* Katakana */ U+3127, /* Bopomofo: 一 (used in zh-TW sometimes) */ U+4E00, U+4E2D, /* Chinese: 一, 中 */ U+65B0, U+65E5, /* 新, 日 */ U+6708-6709, /* 月, 有 */ U+70B9, U+7684, /* 点, 的 */ U+7F51, /* 网 */ U+FF0C, U+FF0E, U+FF1A; /* ,.: */ } ================================================ FILE: quarkdown-html/src/main/typescript/capabilities.ts ================================================ /** * This configuration defines the document handlers to enable, * and it's affected by the HTML wrapper based on the included features. * For instance, if math equations are not used, the math handler is not included. * * @see getGlobalHandlers * @see QuarkdownDocument.getHandlers */ export const capabilities = { /** * Whether to include the code highlighter document handler for syntax highlighting in code blocks. * @see CodeHighlighter */ code: false, /** * Whether to include the math document handler for rendering mathematical formulas. * @see MathRenderer */ math: false, /** * Whether to include the Mermaid diagram document handler for rendering diagrams. * @see DiagramRenderer */ mermaid: false, } ================================================ FILE: quarkdown-html/src/main/typescript/chunker/page-chunker.ts ================================================ import {isBlank} from "../util/visibility"; /** * Utility that splits content into chunks based on page break elements. * * Page breaks come in two forms: * Elements with the `page-break` class (e.g. `<div class="page-break">`, `<h1 class="page-break">`) * start a new chunk and are preserved as the first element of that chunk. * * @example Input: * * ```html * <div class="slides"> * <p>First</p> * <h1 class="page-break">Second</h1> * <p>Content</p> * </div> * ``` * * Output: * * ```html * <div class="slides"> * <section> * <p>First</p> * </section> * <section> * <h1 class="page-break">Second</h1> * <p>Content</p> * </section> * </div> * ``` */ export class PageChunker { private container: HTMLElement; private chunks: HTMLElement[] = []; /** Initializes the chunker with the container element to be chunked. */ constructor(container: HTMLElement) { this.container = container; } /** * Generates chunks based on the page break elements. * Page break elements are not preserved in the chunked output. * @param createElement Function that creates a new chunk element. */ private generateChunks(createElement: () => HTMLElement): void { const chunks: HTMLElement[] = []; let currentChunk = createElement(); Array.from(this.container.children).forEach((child: Element) => { const el = child as HTMLElement; if (el.classList.contains("page-break")) { // Finalize the current chunk and start a new one. chunks.push(currentChunk); currentChunk = createElement(); currentChunk.appendChild(child); } else { // Otherwise, add the child to the current section. currentChunk.appendChild(child); } }); // Add the last section if it has any content. if (currentChunk.childNodes.length > 0) { chunks.push(currentChunk); } this.chunks = chunks; } /** Applies the generated chunks to the container, replacing its content. */ private apply(): void { // Clear out the original slides div and add the new sections. this.container.innerHTML = ""; // Elements that are not part of a section yet and will be added to the next one. let queuedElements: Element[] = []; this.chunks.forEach((chunk: HTMLElement) => { // Empty chunks are ignored. if (isBlank(chunk)) { // If the section is blank and NOT empty, // they are added to the queued elements in order to be added to the next section // and not produce an empty chunk. queuedElements.push(...Array.from(chunk.children)); } else { // If there are any queued elements, they are added to the beginning of the new section. if (queuedElements.length > 0) { queuedElements.forEach(element => chunk.prepend(element)); queuedElements = []; } this.container.appendChild(chunk); } }); // If there are any queued elements left, they are appended to the last visible section. if (queuedElements.length > 0 && this.chunks.length > 0) { const last = this.container.lastElementChild as HTMLElement | null; if (last) { queuedElements.forEach(element => last.appendChild(element)); } queuedElements = []; } } /** * Chunks the container into sections based on page breaks. * Page breaks are not preserved in the output, and empty chunks are ignored. * The container's content is replaced with the chunked sections. * @param chunkTagName The tag name to use for chunk elements (default is "section"). */ chunk(chunkTagName: string = "section"): void { const createElement = (): HTMLElement => { const element = document.createElement(chunkTagName); element.className = "chunk"; return element; }; this.generateChunks(createElement); this.apply(); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/__tests__/document-handler.spec.ts ================================================ import {describe, expect, it, vi} from 'vitest'; import { type ConditionalDocumentHandler, DocumentHandler, filterConditionalHandlers } from '../../document/document-handler'; class TestDoc {} class TestHandler extends DocumentHandler { public inited = false; constructor(doc: any) { super(doc as any); } init() { this.inited = true; } onPreRendering = vi.fn(async () => {}) onPostRendering = vi.fn(async () => {}) } // Minimal pre/post queues shim for testing pushToQueue vi.mock('../../queue/execution-queues', () => { const pushed: any[] = []; const makeQueue = () => ({ items: pushed, pushAsync: (fn: () => Promise<void>) => { pushed.push(fn); }, execute: async () => { for (const fn of pushed) await fn(); pushed.length = 0; } }); return { preRenderingExecutionQueue: makeQueue(), postRenderingExecutionQueue: makeQueue() }; }); describe('filterConditionalHandlers', () => { it('filters out booleans and keeps only DocumentHandler instances', () => { const doc = new TestDoc(); const h1 = new TestHandler(doc); const arr: ConditionalDocumentHandler[] = [h1, true, false]; const result = filterConditionalHandlers(arr); expect(result).toEqual([h1]); }); }); describe('DocumentHandler.pushToQueue', async () => { it('calls init and pushes pre/post rendering handlers if present', async () => { const doc = new TestDoc(); const handler = new TestHandler(doc); // import queues after mock in same module scope const { preRenderingExecutionQueue, postRenderingExecutionQueue } = await import('../../queue/execution-queues'); handler.pushToQueue(); expect(handler.inited).toBe(true); // Execute queued functions to ensure they call the right hooks await preRenderingExecutionQueue.execute(); await postRenderingExecutionQueue.execute(); expect(handler.onPreRendering).toHaveBeenCalledTimes(1); expect(handler.onPostRendering).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/document-handler.ts ================================================ import {QuarkdownDocument} from "./quarkdown-document"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "../queue/execution-queues"; /** * Type representing either a `DocumentHandler` instance or a boolean flag. * Used for conditional inclusion of document handlers. * Only `DocumentHandler` instances are retained for processing. * @see filterConditionalHandlers */ export type ConditionalDocumentHandler = DocumentHandler | boolean; /** * Filters an array of `ConditionalDocumentHandler` to retain only actual `DocumentHandler` instances. * @param handlers - Array of `ConditionalDocumentHandler` (either `DocumentHandler` or boolean) * @returns Array of `DocumentHandler` instances */ export function filterConditionalHandlers(handlers: ConditionalDocumentHandler[]): DocumentHandler[] { return handlers.filter((handler): handler is DocumentHandler => handler instanceof DocumentHandler); } /** * Source of an event or action related to document processing, * with hooks for pre-rendering and post-rendering phases. * @template TDoc - The type of Quarkdown document this handler manages */ export abstract class DocumentHandler<TDoc extends QuarkdownDocument = QuarkdownDocument> { /** * @param quarkdownDocument - The document instance this handler manages */ constructor(protected readonly quarkdownDocument: TDoc) { } /** * Optional initialization hook called when the handler is created. */ init?(): void /** * Hook called before document rendering begins, * via the pre-rendering execution queue. */ async onPreRendering?(): Promise<void> /** * Hook called after document rendering completes, * via the post-rendering execution queue. */ async onPostRendering?(): Promise<void> /** * Pushes this handler's lifecycle methods to the appropriate execution queues. * Pre-rendering handlers are added to the pre-rendering queue, * post-rendering handlers are added to the post-rendering queue. */ pushToQueue() { this.init?.(); if (this.onPreRendering) { preRenderingExecutionQueue.pushAsync(() => this.onPreRendering!()); } if (this.onPostRendering) { postRenderingExecutionQueue.pushAsync(() => this.onPostRendering!()); } } } ================================================ FILE: quarkdown-html/src/main/typescript/document/global-handlers.ts ================================================ import {capabilities} from "../capabilities"; import {ConditionalDocumentHandler} from "./document-handler"; import {InlineCollapsibles} from "./handlers/inline-collapsibles"; import {QuarkdownDocument} from "./quarkdown-document"; import {RemainingHeight} from "./handlers/remaining-height"; import {MathRenderer} from "./handlers/capabilities/math-renderer"; import {CodeHighlighter} from "./handlers/capabilities/code-highlighter"; import {MermaidRenderer} from "./handlers/capabilities/mermaid-renderer"; /** Global document handlers that apply to all documents. */ export function getGlobalHandlers(document: QuarkdownDocument): ConditionalDocumentHandler[] { return [ new InlineCollapsibles(document), new RemainingHeight(document), capabilities.code && new CodeHighlighter(document), capabilities.math && new MathRenderer(document), capabilities.mermaid && new MermaidRenderer(document), ] } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/code-highlighter.spec.ts ================================================ import {describe, expect, it, vi} from 'vitest'; import {CodeHighlighter} from "../capabilities/code-highlighter"; // mock hljs and plugin // @ts-expect-error define global globalThis.CopyButtonPlugin = function(){} as any; // mock hljs const highlightAll = vi.fn(); const lineNumbersBlockSync = vi.fn(); const addPlugin = vi.fn(); // @ts-expect-error define global hljs globalThis.hljs = { highlightAll, lineNumbersBlockSync, addPlugin } as any; class DummyDoc {} describe('CodeHighlighter', () => { it('initializes plugin, highlights, adds line numbers and focuses lines', async () => { document.body.innerHTML = ` <pre><code class="hljs">code</code></pre> <pre><code class="focus-lines" data-focus-start="2" data-focus-end="3"> <div class="hljs-ln-line" data-line-number="1"></div> <div class="hljs-ln-line" data-line-number="2"></div> <div class="hljs-ln-line" data-line-number="3"></div> <div class="hljs-ln-line" data-line-number="4"></div> </code></pre>`; const h = new CodeHighlighter(new DummyDoc() as any); h.init(); expect(addPlugin).toHaveBeenCalled(); await h.onPostRendering(); expect(highlightAll).toHaveBeenCalled(); expect(lineNumbersBlockSync).toHaveBeenCalled(); const focused = document.querySelectorAll('.hljs-ln-line.focused'); expect(focused.length).toBe(2); // lines 2 and 3 }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/footnotes-document-handler.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {FootnotesDocumentHandler} from "../footnotes/footnotes-document-handler"; class DummyDoc { } class Concrete extends FootnotesDocumentHandler { } describe('FootnotesDocumentHandler', () => { it('collects footnote pairs on pre-rendering', async () => { document.body.innerHTML = ` <div> <span class="footnote-reference" data-definition="def-1">[1]</span> <span class="footnote-reference" data-definition="def-1">[1.2]</span> <div class="footnote-definition" id="def-1" data-footnote-index="1"></div> <div class="footnote-definition" id="def-2" data-footnote-index="2"></div> </div>`; const handler = new Concrete(new DummyDoc() as any); await handler.onPreRendering(); // @ts-expect-error access for testing protected field const footnotes = handler.footnotes; expect(footnotes.length).toBe(1); expect(footnotes[0].reference?.textContent).toBe('[1]'); expect(footnotes[0].definition.id).toBe('def-1'); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/inline-collapsibles.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {InlineCollapsibles} from "../inline-collapsibles"; class DummyDoc { } describe('InlineCollapsibles', () => { it('toggles between full and collapsed content using innerHTML for user-defined', async () => { document.body.innerHTML = ` <span class="inline-collapse" data-full-text="FULL <b>X</b>" data-collapsed-text="COLL" data-collapsed="true"></span>`; const h = new InlineCollapsibles(new DummyDoc() as any); await h.onPostRendering(); const span = document.querySelector('.inline-collapse') as HTMLElement; // Click should toggle to full text (since it was collapsed) span.click(); expect(span.dataset.collapsed).toBe('false'); expect(span.innerHTML).toBe('FULL <b>X</b>'); // Click back should collapse and set innerHTML to collapsed text span.click(); expect(span.dataset.collapsed).toBe('true'); expect(span.innerHTML).toBe('COLL'); }); it('uses textContent when inside .error', async () => { document.body.innerHTML = ` <div class="error"> <span class="inline-collapse" data-full-text="<i>FULL</i>" data-collapsed-text="COLL" data-collapsed="true"></span> </div>`; const handler = new InlineCollapsibles(new DummyDoc() as any); await handler.onPostRendering(); const span = document.querySelector('.inline-collapse') as HTMLElement; span.click(); // inside error -> textContent should be used, not interpreted HTML (literal string with tags) expect(span.textContent).toBe('<i>FULL</i>'); span.click(); expect(span.textContent).toBe('COLL'); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/math-renderer.spec.ts ================================================ import {describe, expect, it, vi} from 'vitest'; import {MathRenderer} from "../capabilities/math-renderer"; // mock global katex globalThis.katex = {renderToString: vi.fn(() => '<span class="katex">OK</span>')} as any; class DummyDoc { } describe('MathRenderer', () => { it('renders inline and block formulas with macros', async () => { // @ts-ignore globalThis.window = globalThis.window || ({} as any); (window as any).texMacros = {R: "\\mathbb{R}"}; document.body.innerHTML = ` <div> <formula>1+1</formula> <formula data-block>2+2</formula> </div>`; const h = new MathRenderer(new DummyDoc() as any); await h.onPreRendering(); const formulas = document.querySelectorAll('formula'); expect(formulas[0].innerHTML).toContain('katex'); expect(formulas[1].innerHTML).toContain('katex'); expect((globalThis as any).katex.renderToString).toHaveBeenCalled(); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/mermaid-renderer.spec.ts ================================================ import {beforeEach, describe, expect, it, vi} from 'vitest'; import {MermaidRenderer} from "../capabilities/mermaid-renderer"; // mock mermaid // @ts-ignore globalThis.mermaid = { initialize: vi.fn(), render: vi.fn(async (id: string, code: string) => ({svg: `<svg id="${id}">${code}</svg>`})) } as any; class DummyDoc { } beforeEach(() => { sessionStorage.clear(); }); describe('MermaidRenderer', () => { it('renders and caches diagrams', async () => { const html = `<div class="mermaid">graph TD; A-->B;</div>` document.body.innerHTML = html; const handler = new MermaidRenderer(new DummyDoc() as any); // first render -> uses render() await handler.onPreRendering(); const el = document.querySelector<HTMLElement>('.mermaid')!; expect(el.dataset.processed).toBe('true'); expect(el.querySelector('svg')).toBeTruthy(); document.body.innerHTML = html; // second render -> uses cache await handler.onPreRendering(); expect((globalThis as any).mermaid.render).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/page-margins-docs.spec.ts ================================================ import {beforeEach, describe, expect, it} from 'vitest'; import {PageMarginsDocs} from "../page-margins/page-margins-docs"; import {QuarkdownDocument} from "../../quarkdown-document"; import {DocumentHandler} from "../../document-handler"; class DummyDocument implements QuarkdownDocument { getParentViewport(): HTMLElement | undefined { return undefined; } setupPreRenderingHook(): void { } setupPostRenderingHook(): void { } initializeRendering(): void { } getHandlers(): DocumentHandler[] { return []; } } const createInitializer = (position: string, content: string = '', extraClass: string = ''): string => { const classes = `page-margin-content${extraClass ? ` ${extraClass}` : ''}`; return `<div class="${classes}" data-on-left-page="${position}" data-on-right-page="${position}">${content}</div>`; }; const setupDocsStructure = (initializers: string = ''): void => { document.body.innerHTML = ` <header> <aside class="margin-area"></aside> <main></main> <aside class="margin-area"></aside> </header> <div class="content-wrapper"> <aside class="margin-area"> <div class="position-top"></div> <div class="position-middle"></div> <div class="position-bottom"></div> </aside> <main> ${initializers} <footer> <div class="position-left"></div> <div class="position-center"></div> <div class="position-right"></div> </footer> </main> <aside class="margin-area"> <div class="position-top"></div> <div class="position-middle"></div> <div class="position-bottom"></div> </aside> </div> `; }; describe('PageMarginsDocs', () => { let handler: PageMarginsDocs; beforeEach(() => { handler = new PageMarginsDocs(new DummyDocument()); }); describe('header positions', () => { it('moves top-left-corner to header left aside', async () => { setupDocsStructure(createInitializer('top-left-corner', 'TLC')); await handler.onPostRendering(); const target = document.querySelector('header > aside:first-child'); expect(target?.querySelector('.page-margin-top-left-corner')?.textContent).toBe('TLC'); }); it('moves top-left to header left aside', async () => { setupDocsStructure(createInitializer('top-left', 'TL')); await handler.onPostRendering(); const target = document.querySelector('header > aside:first-child'); expect(target?.querySelector('.page-margin-top-left')?.textContent).toBe('TL'); }); it('moves top-center to header main', async () => { setupDocsStructure(createInitializer('top-center', 'TC')); await handler.onPostRendering(); const target = document.querySelector('header > main'); expect(target?.querySelector('.page-margin-top-center')?.textContent).toBe('TC'); }); it('moves top-right-corner to header right aside', async () => { setupDocsStructure(createInitializer('top-right-corner', 'TRC')); await handler.onPostRendering(); const target = document.querySelector('header > aside:last-child'); expect(target?.querySelector('.page-margin-top-right-corner')?.textContent).toBe('TRC'); }); it('moves top-right to header right aside', async () => { setupDocsStructure(createInitializer('top-right', 'TR')); await handler.onPostRendering(); const target = document.querySelector('header > aside:last-child'); expect(target?.querySelector('.page-margin-top-right')?.textContent).toBe('TR'); }); }); describe('left sidebar positions', () => { it('moves left-top to sidebar position-top', async () => { setupDocsStructure(createInitializer('left-top', 'LT')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:first-child > .position-top'); expect(target?.querySelector('.page-margin-left-top')?.textContent).toBe('LT'); }); it('moves left-middle to sidebar position-middle', async () => { setupDocsStructure(createInitializer('left-middle', 'LM')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:first-child > .position-middle'); expect(target?.querySelector('.page-margin-left-middle')?.textContent).toBe('LM'); }); it('moves left-bottom to sidebar position-bottom', async () => { setupDocsStructure(createInitializer('left-bottom', 'LB')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:first-child > .position-bottom'); expect(target?.querySelector('.page-margin-left-bottom')?.textContent).toBe('LB'); }); it('moves bottom-left-corner to sidebar position-bottom', async () => { setupDocsStructure(createInitializer('bottom-left-corner', 'BLC')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:first-child > .position-bottom'); expect(target?.querySelector('.page-margin-bottom-left-corner')?.textContent).toBe('BLC'); }); }); describe('right sidebar positions', () => { it('moves right-top to sidebar position-top', async () => { setupDocsStructure(createInitializer('right-top', 'RT')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:last-child > .position-top'); expect(target?.querySelector('.page-margin-right-top')?.textContent).toBe('RT'); }); it('moves right-middle to sidebar position-middle', async () => { setupDocsStructure(createInitializer('right-middle', 'RM')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:last-child > .position-middle'); expect(target?.querySelector('.page-margin-right-middle')?.textContent).toBe('RM'); }); it('moves right-bottom to sidebar position-bottom', async () => { setupDocsStructure(createInitializer('right-bottom', 'RB')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:last-child > .position-bottom'); expect(target?.querySelector('.page-margin-right-bottom')?.textContent).toBe('RB'); }); it('moves bottom-right-corner to sidebar position-bottom', async () => { setupDocsStructure(createInitializer('bottom-right-corner', 'BRC')); await handler.onPostRendering(); const target = document.querySelector('.content-wrapper > aside:last-child > .position-bottom'); expect(target?.querySelector('.page-margin-bottom-right-corner')?.textContent).toBe('BRC'); }); }); describe('footer positions', () => { it('moves bottom-left to footer position-left', async () => { setupDocsStructure(createInitializer('bottom-left', 'BL')); await handler.onPostRendering(); const target = document.querySelector('footer > .position-left'); expect(target?.querySelector('.page-margin-bottom-left')?.textContent).toBe('BL'); }); it('moves bottom-center to footer position-center', async () => { setupDocsStructure(createInitializer('bottom-center', 'BC')); await handler.onPostRendering(); const target = document.querySelector('footer > .position-center'); expect(target?.querySelector('.page-margin-bottom-center')?.textContent).toBe('BC'); }); it('moves bottom-right to footer position-right', async () => { setupDocsStructure(createInitializer('bottom-right', 'BR')); await handler.onPostRendering(); const target = document.querySelector('footer > .position-right'); expect(target?.querySelector('.page-margin-bottom-right')?.textContent).toBe('BR'); }); }); describe('wrapper creation', () => { it('preserves extra classes from initializer', async () => { setupDocsStructure(createInitializer('top-center', 'content', 'custom-class another-class')); await handler.onPostRendering(); const wrapper = document.querySelector('header > main > div'); expect(wrapper?.classList.contains('page-margin-top-center')).toBe(true); expect(wrapper?.classList.contains('page-margin-content')).toBe(true); expect(wrapper?.classList.contains('custom-class')).toBe(true); expect(wrapper?.classList.contains('another-class')).toBe(true); }); it('preserves HTML content', async () => { setupDocsStructure(createInitializer('top-center', '<strong>Bold</strong> text')); await handler.onPostRendering(); const wrapper = document.querySelector('header > main > .page-margin-top-center'); expect(wrapper?.innerHTML).toBe('<strong>Bold</strong> text'); }); }); describe('cleanup', () => { it('removes initializers after processing', async () => { setupDocsStructure(createInitializer('top-center', 'content')); await handler.onPostRendering(); const remaining = document.querySelectorAll('.content-wrapper > main > .page-margin-content'); expect(remaining.length).toBe(0); }); it('removes initializers with unknown positions', async () => { setupDocsStructure(createInitializer('unknown-position', 'content')); await handler.onPostRendering(); const remaining = document.querySelectorAll('.page-margin-content'); expect(remaining.length).toBe(0); }); }); describe('multiple margins', () => { it('handles multiple margins in different positions', async () => { setupDocsStructure(` ${createInitializer('top-left', 'TL')} ${createInitializer('bottom-center', 'BC')} ${createInitializer('right-middle', 'RM')} `); await handler.onPostRendering(); expect(document.querySelector('header > aside:first-child > .page-margin-top-left')?.textContent).toBe('TL'); expect(document.querySelector('footer > .position-center > .page-margin-bottom-center')?.textContent).toBe('BC'); expect(document.querySelector('.content-wrapper > aside:last-child > .position-middle > .page-margin-right-middle')?.textContent).toBe('RM'); }); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/page-margins-document-handler.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {PageMarginsDocumentHandler} from "../page-margins/page-margins-document-handler"; import {PagedLikeQuarkdownDocument, QuarkdownPage} from "../../paged-like-quarkdown-document"; class DummyDocument implements PagedLikeQuarkdownDocument<QuarkdownPage> { constructor(private readonly pages: QuarkdownPage[] = [], private readonly types: Array<'left'|'right'> = []) { } getPages(): QuarkdownPage[] { return this.pages; } getPageNumber(_page: QuarkdownPage): number { return 1; } getDisplayPageNumber(_page: QuarkdownPage): string { return "1"; } getPageType(page: QuarkdownPage): 'left' | 'right' { const index = this.pages.indexOf(page); return this.types[index] ?? 'right'; } getPage(_element: HTMLElement): QuarkdownPage | undefined { return undefined; } setDisplayPageNumber(_page: QuarkdownPage, _pageNumber: string): void { } getParentViewport(_element: Element): HTMLElement | undefined { return undefined; } setupPreRenderingHook(): void { } setupPostRenderingHook(): void { } initializeRendering(): void { } getHandlers() { return []; } } class RecordingHandler extends PageMarginsDocumentHandler { public readonly applied: Array<{pageIndex: number, marginId: string | undefined, marginPosition: string}> = []; apply(initializer: HTMLElement, page: QuarkdownPage, marginPositionName: string) { this.applied.push({ pageIndex: (this.quarkdownDocument.getPages().indexOf(page)), marginId: initializer.dataset.marginId, marginPosition: marginPositionName, }); } } const createInitializer = (className: string, leftPosition: string, rightPosition: string): HTMLElement => { const div = document.createElement('div'); div.className = className; div.dataset.onLeftPage = leftPosition; div.dataset.onRightPage = rightPosition; return div; }; const createPage = (initializers: HTMLElement[] = []): QuarkdownPage => { const container = document.createElement('div'); initializers.forEach(init => container.appendChild(init)); return { querySelectorAll(selector: string) { return container.querySelectorAll(selector); }, } as QuarkdownPage; }; describe('PageMarginsDocumentHandler', () => { it('hides page margin initializers on pre-rendering', async () => { document.body.innerHTML = ` <div> <div class="page-margin-content" id="a" data-on-left-page="bottom-center" data-on-right-page="bottom-center"></div> <div class="page-margin-content" id="b" data-on-left-page="top-left" data-on-right-page="top-left"></div> <div class="other"></div> </div>`; const handler = new RecordingHandler(new DummyDocument()); await handler.onPreRendering(); const initializers = document.querySelectorAll<HTMLElement>('.page-margin-content'); expect(initializers.length).toBe(2); initializers.forEach(init => { expect(init.style.display).toBe('none'); }); }); it('applies page margins starting from the page where they are defined', async () => { // Margin defined on page 1 should appear on pages 1, 2, and 3. const marginOnPage1 = createInitializer('page-margin-content margin-a', 'bottom-center', 'bottom-center'); // Margin defined on page 2 should appear on pages 2 and 3. const marginOnPage2 = createInitializer('page-margin-content margin-b', 'top-left', 'top-left'); const pages = [ createPage([marginOnPage1]), createPage([marginOnPage2]), createPage([]), ]; const doc = new DummyDocument(pages, ['right', 'left', 'right']); const handler = new RecordingHandler(doc); await handler.onPreRendering(); await handler.onPostRendering(); expect(handler.applied).toEqual([ // Page 0: only margin-a is active {pageIndex: 0, marginId: undefined, marginPosition: 'bottom-center'}, // Page 1: margin-a persists, margin-b starts {pageIndex: 1, marginId: undefined, marginPosition: 'bottom-center'}, {pageIndex: 1, marginId: undefined, marginPosition: 'top-left'}, // Page 2: both margins persist {pageIndex: 2, marginId: undefined, marginPosition: 'bottom-center'}, {pageIndex: 2, marginId: undefined, marginPosition: 'top-left'}, ]); }); it('uses correct position based on page type (left/right)', async () => { // Margin with different positions for left and right pages. const margin = createInitializer('page-margin-content margin-mirror', 'left-center', 'right-center'); const pages = [ createPage([margin]), createPage([]), ]; const doc = new DummyDocument(pages, ['right', 'left']); const handler = new RecordingHandler(doc); await handler.onPreRendering(); await handler.onPostRendering(); expect(handler.applied).toEqual([ {pageIndex: 0, marginId: undefined, marginPosition: 'right-center'}, {pageIndex: 1, marginId: undefined, marginPosition: 'left-center'}, ]); }); it('overrides previous margin when a new one with the same class is defined', async () => { // Two margins with the same class on different pages. const marginV1 = createInitializer('page-margin-content footer', 'bottom-center', 'bottom-center'); const marginV2 = createInitializer('page-margin-content footer', 'top-center', 'top-center'); const pages = [ createPage([marginV1]), createPage([marginV2]), createPage([]), ]; const doc = new DummyDocument(pages, ['right', 'right', 'right']); const handler = new RecordingHandler(doc); await handler.onPreRendering(); await handler.onPostRendering(); expect(handler.applied).toEqual([ // Page 0: uses marginV1 {pageIndex: 0, marginId: undefined, marginPosition: 'bottom-center'}, // Page 1: marginV2 overrides marginV1 {pageIndex: 1, marginId: undefined, marginPosition: 'top-center'}, // Page 2: marginV2 persists {pageIndex: 2, marginId: undefined, marginPosition: 'top-center'}, ]); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/page-numbers-document-handler.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {PageNumbers} from "../page-numbers"; import {PagedLikeQuarkdownDocument} from "../../paged-like-quarkdown-document"; class DummyDocument implements PagedLikeQuarkdownDocument<HTMLElement> { constructor(private readonly pages: HTMLElement[]) { } getPages(): HTMLElement[] { return this.pages; } getPageNumber(page: HTMLElement): number { return parseInt(page.dataset.pageNumber || "0", 10); } getDisplayPageNumber(page: HTMLElement): string { return page.dataset.displayPageNumber ?? this.getPageNumber(page).toString(); } getPageType(): "left" | "right" { return "right"; } getPage(element: HTMLElement): HTMLElement | undefined { return this.pages.find(page => page.contains(element)); } setDisplayPageNumber(page: HTMLElement, pageNumber: string): void { page.setAttribute("data-display-page-number", pageNumber); } getParentViewport(): HTMLElement | undefined { return undefined; } setupPreRenderingHook(): void { } setupPostRenderingHook(): void { } initializeRendering(): void { } getHandlers() { return []; } } class Concrete extends PageNumbers { } describe('PageNumbersDocumentHandler', () => { it('respects page number reset markers when numbering pages', async () => { document.body.className = 'quarkdown quarkdown-paged'; document.body.innerHTML = ` <div class="pagedjs_page" data-page-number="1"> <span class="current-page-number">X</span> </div> <div class="pagedjs_page" data-page-number="2"> <span class="page-number-reset" data-start="10"></span> <span class="current-page-number">Y</span> </div> <div class="pagedjs_page" data-page-number="3"> <span class="current-page-number">Z</span> </div>`; const pages = Array.from(document.querySelectorAll<HTMLElement>('.pagedjs_page')); const handler = new Concrete(new DummyDocument(pages)); await handler.onPostRendering(); const numbers = pages.map(page => page.querySelector('.current-page-number')?.textContent); expect(numbers).toEqual(['1', '10', '11']); expect(pages.map(page => page.dataset.displayPageNumber)).toEqual(['1', '10', '11']); const totals = Array.from(document.querySelectorAll('.total-page-number')); expect(totals.length).toBe(0); }); it('injects reset-aware numbers into table of contents entries for paged documents', async () => { document.body.className = 'quarkdown quarkdown-paged'; document.body.innerHTML = ` <div class="pagedjs_page" data-page-number="1"> <div class="pagedjs_area"> <h1 id="table-of-contents">Contents</h1> <nav data-role="table-of-contents"> <ol> <li><a href="#section-1">Section 1</a></li> <li><a href="#section-2">Section 2</a></li> </ol> </nav> <h2 id="section-1">Section 1</h2> <span class="current-page-number">X</span> </div> </div> <div class="pagedjs_page" data-page-number="2"> <div class="pagedjs_area"> <span class="page-number-reset" data-start="5"></span> <h2 id="section-2">Section 2</h2> <span class="current-page-number">Y</span> </div> </div>`; const pages = Array.from(document.querySelectorAll<HTMLElement>('.pagedjs_page')); const handler = new Concrete(new DummyDocument(pages)); await handler.onPostRendering(); const numbers = Array.from(document.querySelectorAll<HTMLSpanElement>('.toc-page-number')).map(span => span.textContent); expect(numbers).toEqual(['1', '5']); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/persistent-headings.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {PersistentHeadings} from "../persistent-headings"; import {PagedLikeQuarkdownDocument} from "../../paged-like-quarkdown-document"; import {ConditionalDocumentHandler} from "../../document-handler"; import {prepare} from "../../quarkdown-document"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "../../../queue/execution-queues"; class DummyDoc implements PagedLikeQuarkdownDocument { getHandlers(): ConditionalDocumentHandler[] { return [new PersistentHeadings(this)]; } getParentViewport(element: Element): HTMLElement | undefined { return document.documentElement; } initializeRendering(): void { postRenderingExecutionQueue.execute().then(); } setupPostRenderingHook(): void { } setupPreRenderingHook(): void { preRenderingExecutionQueue.execute().then(); } getPageNumber(_: HTMLElement): number { return 0; } getDisplayPageNumber(_: HTMLElement): string { return "0"; } getPageType(_: HTMLElement): "left" | "right" { return "left"; } getPages(): HTMLElement[] { return [document.documentElement]; } } describe('PersistentHeadingsDocumentHandler', () => { it('stores last heading per depth and applies to .last-heading', () => { // Build a page with two containers: source and target const container = document.createElement('div'); container.innerHTML = ` <h1>Main</h1> <h2>Section A</h2> <h3 data-decorative>Decor</h3> <h2>Section B</h2> <div class="last-heading" data-depth="1"></div> <div class="last-heading" data-depth="2"></div> <div class="last-heading" data-depth="3"></div>`; document.body.appendChild(container); postRenderingExecutionQueue.addOnComplete(() => { const [h1, h2, h3] = Array.from(container.querySelectorAll('.last-heading')) as HTMLElement[]; expect(h1.innerHTML).toBe('Main'); expect(h2.innerHTML).toBe('Section B'); expect(h3.innerHTML).toBe(''); // no h3 persisted because decorative + cleared after depth 2 }); prepare(new DummyDoc()); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/__tests__/remaining-height.spec.ts ================================================ import {describe, expect, it} from 'vitest'; import {RemainingHeight} from "../remaining-height"; class DocStub { getParentViewport(el: HTMLElement): HTMLElement | null { return document.querySelector('#viewport'); } } describe('RemainingHeight', () => { it('sets --viewport-remaining-height on .fill-height elements', async () => { document.body.innerHTML = ` <div id="viewport"></div> <div class="fill-height" id="content"></div>`; const viewport = document.getElementById('viewport') as any; const content = document.getElementById('content') as any; // Mock layout boxes viewport.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, bottom: 800, width: 0, height: 800, x: 0, y: 0, toJSON() { } } as any); content.getBoundingClientRect = () => ({ top: 300, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() { } } as any); const handler = new RemainingHeight(new DocStub() as any); await handler.onPostRendering(); const value = (content as HTMLElement).style.getPropertyValue('--viewport-remaining-height'); expect(value).toBe('500px'); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/capabilities/code-highlighter.ts ================================================ import {DocumentHandler} from "../../document-handler"; /** * Type declaration for the highlight.js library with line numbers plugin support. */ declare const hljs: typeof import("highlight.js").default & { lineNumbersBlockSync: (element: Element) => void; }; /** * Type declaration for the copy button plugin used with highlight.js. */ declare const CopyButtonPlugin: { new(...args: any[]): any }; /** * Document handler that provides syntax highlighting and code enhancement features. * * This handler integrates with highlight.js to provide: * - Syntax highlighting * - Line numbering * - Line focusing to emphasize specific code sections * - Copy-to-clipboard */ export class CodeHighlighter extends DocumentHandler { init() { hljs.addPlugin(new CopyButtonPlugin()); } async onPostRendering() { hljs.highlightAll(); this.initLineNumbers(); this.focusCodeLines(); } /** * Adds line numbers to code blocks with the 'hljs' class, excluding those marked * with 'nohljsln' class. */ private initLineNumbers() { const codeBlocks = document.querySelectorAll('code.hljs:not(.nohljsln)'); codeBlocks.forEach((code) => { hljs.lineNumbersBlockSync(code); }); } /** * Applies visual focus to specific line ranges in code blocks. * * This method processes code blocks with the 'focus-lines' class and highlights * lines within the specified range using 'data-focus-start' and 'data-focus-end' * attributes. Supports open ranges where either start or end can be omitted. * * Range behavior: * - If start is NaN or missing: focuses from beginning up to end * - If end is NaN or missing: focuses from start to the last line * - If both are specified: focuses the exact range (inclusive) * * @example * ```html * <!-- Focus lines 5-10 --> * <code class="focus-lines" data-focus-start="5" data-focus-end="10">...</code> * * <!-- Focus from line 3 to end --> * <code class="focus-lines" data-focus-start="3">...</code> * * <!-- Focus from beginning to line 8 --> * <code class="focus-lines" data-focus-end="8">...</code> * ``` */ private focusCodeLines() { const focusableCodeBlocks = document.querySelectorAll<HTMLElement>('code.focus-lines'); focusableCodeBlocks.forEach((codeBlock) => { const focusRange = this.extractFocusRange(codeBlock); this.applyFocusToLines(codeBlock, focusRange); }); } /** * Extracts the focus range from a code block's data attributes. * * @param codeBlock The code block element to extract range from * @returns An object containing the parsed start and end line numbers */ private extractFocusRange(codeBlock: HTMLElement): { start: number; end: number } { const start = parseInt(codeBlock.dataset.focusStart || '0'); const end = parseInt(codeBlock.dataset.focusEnd || '0'); return { start, end }; } /** * Applies the 'focused' CSS class to lines within the specified range. * * @param codeBlock The code block containing the lines to focus * @param focusRange Object containing start and end line numbers */ private applyFocusToLines(codeBlock: HTMLElement, focusRange: { start: number; end: number }) { const lines = codeBlock.querySelectorAll<HTMLElement>('.hljs-ln-line'); lines.forEach(line => { const lineNumber = parseInt(line.dataset.lineNumber || '0'); if (this.isLineInFocusRange(lineNumber, focusRange)) { line.classList.add('focused'); } }); } /** * Determines if a line number falls within the focus range. * * Supports open ranges where NaN values indicate unbounded ranges: * - NaN start means focus from beginning * - NaN end means focus to the end * * @param lineNumber The line number to check * @param focusRange The focus range with start and end boundaries * @returns True if the line should be focused, false otherwise */ private isLineInFocusRange(lineNumber: number, focusRange: { start: number; end: number }): boolean { const { start, end } = focusRange; const isAfterStart = isNaN(start) || lineNumber >= start; const isBeforeEnd = isNaN(end) || lineNumber <= end; return isAfterStart && isBeforeEnd; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/capabilities/math-renderer.ts ================================================ import {DocumentHandler} from "../../document-handler"; /** * Type declaration for the KaTeX library used for rendering mathematical formulas. */ declare const katex: typeof import("katex"); /** * Global window interface extension to support TeX macro definitions. */ declare global { interface Window { /** Optional object containing TeX macro definitions for mathematical formulas */ texMacros?: {[key: string]: string}; } } /** * Document handler that processes and renders mathematical formulas using KaTeX. * * This handler converts LaTeX mathematical expressions found in `<formula>` elements * into rendered HTML using the KaTeX library. It supports both inline and block-level * mathematical expressions and can utilize custom TeX macros if defined globally. * * The handler operates during the pre-rendering phase to ensure mathematical * content is processed before document rendering is finalized. */ export class MathRenderer extends DocumentHandler { async onPreRendering() { const texMacros = window.texMacros; const formulas = document.querySelectorAll<HTMLElement>('formula'); formulas.forEach((formula) => { const content = formula.textContent; const isBlock = formula.dataset.block === ''; if (!content) return; formula.innerHTML = katex.renderToString(content, { throwOnError: false, displayMode: isBlock, macros: texMacros || {}, }); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/capabilities/mermaid-renderer.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {hashCode} from "../../../util/hash"; /** * Type declaration for the Mermaid library used for rendering diagrams. */ declare const mermaid: typeof import("mermaid").default; /** * Document handler that renders Mermaid diagrams from textual descriptions. * * This handler processes elements with the 'mermaid' class and converts their * text content into rendered SVG diagrams using the Mermaid library. * It includes caching mechanisms for performance and automatic scaling for better presentation. */ export class MermaidRenderer extends DocumentHandler { init() { mermaid.initialize({startOnLoad: false}); } /** Processes all Mermaid diagrams in the document. */ async onPreRendering() { const diagrams = document.querySelectorAll<HTMLElement>('.mermaid:not([data-processed])'); const renderPromises = Array.from(diagrams).map( (element) => this.loadFromCacheOrRender(element) ); await Promise.all(renderPromises) this.realignDiagramContents(); } /** * Renders a single Mermaid diagram element, using cached results when available. * * The caching mechanism uses session storage with a hash of the diagram content * as the key. This ensures that identical diagrams are only rendered once per * browser session, significantly improving performance for documents with * repeated or unchanged diagrams. * * @param element The HTML element containing the Mermaid diagram text */ private async loadFromCacheOrRender(element: HTMLElement) { const code = element.textContent?.trim() || ''; const id = 'mermaid-' + hashCode(code); const cachedSvg = sessionStorage.getItem(id); element.dataset.processed = 'true'; if (cachedSvg) { console.debug('Using cached SVG for diagram:', id); element.innerHTML = cachedSvg; return; } console.debug('Rendering diagram:', id); const diagram = await mermaid.render(id, code, element); console.log(diagram); const svg = diagram.svg; element.innerHTML = svg; sessionStorage.setItem(id, svg); } /** * Calculates an appropriate scale percentage for a diagram based on its aspect ratio. * * Uses a scaling formula that considers the diagram's width-to-height ratio * to determine an optimal display size. Wider diagrams get larger scales while * taller diagrams are kept more compact. * * @param svg The SVG element containing the rendered diagram * @returns A percentage value (0-100) representing the optimal scale */ private calculateNewDiagramScale(svg: SVGSVGElement) { const scaleFactor = 0.2; const scaleOffset = 0.4; const maxScale = 100; const width = svg.viewBox.baseVal.width || svg.clientWidth || 1; const height = svg.viewBox.baseVal.height || svg.clientHeight || 1; const aspectRatio = width / height; const scale = (scaleOffset + scaleFactor * aspectRatio) * maxScale; return Math.min(maxScale, scale); } /** * Applies styling adjustments to improve diagram presentation and alignment. */ private realignDiagramContents() { document.querySelectorAll<HTMLElement>('.mermaid').forEach(diagram => { diagram.style.width = '100%'; const svg = diagram.querySelector('svg'); if (!svg) return; svg.style.width = this.calculateNewDiagramScale(svg) + '%'; }); document.querySelectorAll<HTMLElement>('.mermaid foreignObject').forEach(obj => { obj.style.display = 'grid'; }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/__tests__/sibling-pages-buttons.spec.ts ================================================ import {beforeEach, describe, expect, it} from 'vitest'; import {SiblingPagesButtons} from "../sibling-pages-buttons"; class DummyDoc { } describe('SiblingPagesButtons', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('does nothing when button area does not exist', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> <li><a href="/c">C</a></li> </ol> </nav>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); expect(document.getElementById('previous-page-anchor')).toBeNull(); expect(document.getElementById('next-page-anchor')).toBeNull(); }); it('creates both links when previous and next exist', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> <li><a href="/c">C</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); const prevLink = document.getElementById('previous-page-anchor') as HTMLAnchorElement; const nextLink = document.getElementById('next-page-anchor') as HTMLAnchorElement; expect(prevLink).not.toBeNull(); expect(nextLink).not.toBeNull(); expect(prevLink.getAttribute('href')).toBe('/a'); expect(nextLink.getAttribute('href')).toBe('/c'); }); it('places icon at start for previous link', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); const prevLink = document.getElementById('previous-page-anchor') as HTMLAnchorElement; const icon = prevLink.querySelector('i') as HTMLElement; expect(icon.className).toBe('bi bi-arrow-left'); expect(prevLink.firstChild).toBe(icon); }); it('places icon at end for next link', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a" aria-current="page">A</a></li> <li><a href="/b">B</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); const nextLink = document.getElementById('next-page-anchor') as HTMLAnchorElement; const icon = nextLink.querySelector('i') as HTMLElement; expect(icon.className).toBe('bi bi-arrow-right'); expect(nextLink.lastChild).toBe(icon); }); it('only creates previous link when at last page', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); expect(document.getElementById('previous-page-anchor')).not.toBeNull(); expect(document.getElementById('next-page-anchor')).toBeNull(); }); it('only creates next link when at first page', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a" aria-current="page">A</a></li> <li><a href="/b">B</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); expect(document.getElementById('previous-page-anchor')).toBeNull(); expect(document.getElementById('next-page-anchor')).not.toBeNull(); }); it('clones the link without modifying the original', async () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a" aria-current="page">A</a></li> <li><a href="/b">B</a></li> </ol> </nav> <div id="sibling-pages-button-area"></div>`; const originalLink = document.querySelector('a[href="/b"]') as HTMLAnchorElement; const originalChildCount = originalLink.childNodes.length; const handler = new SiblingPagesButtons(new DummyDoc() as any); await handler.onPostRendering(); expect(originalLink.childNodes.length).toBe(originalChildCount); expect(originalLink.querySelector('i')).toBeNull(); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/page-list-autoscroll.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {isSafari} from "../../../util/browser"; const PAGE_LIST_SELECTOR = 'nav[data-role="page-list"]'; const CURRENT_PAGE_SELECTOR = "[aria-current]"; const STORAGE_KEY = "qd-page-list-scroll"; /** * Document handler that scrolls the page list sidebar to show the current page. * Restores the previous scroll position from sessionStorage, then smooth scrolls * to bring the current page into view. * * On Safari, only the scroll position is restored without further adjustments, * as Safari's smooth scroll does not respect a programmatically set starting position. */ export class PageListAutoscroll extends DocumentHandler { async onPostRendering() { const pageList = document.querySelector(PAGE_LIST_SELECTOR); if (!pageList) return; const currentPage = pageList.querySelector(CURRENT_PAGE_SELECTOR) as HTMLElement | null; if (!currentPage) return; const aside = currentPage.closest("aside") as HTMLElement | null; if (!aside) return; this.restoreScrollPosition(aside); if (!isSafari()) { this.scrollToCurrentPage(aside, currentPage); } this.saveScrollPositionOnScroll(aside); } private restoreScrollPosition(aside: HTMLElement) { const savedScrollTop = sessionStorage.getItem(STORAGE_KEY); if (savedScrollTop !== null) { aside.scrollTop = parseFloat(savedScrollTop); } } private scrollToCurrentPage(aside: HTMLElement, currentPage: HTMLElement) { const asideRect = aside.getBoundingClientRect(); const currentRect = currentPage.getBoundingClientRect(); const targetScrollTop = Math.max(0, currentRect.top - asideRect.top + aside.scrollTop - aside.clientHeight / 4); aside.scrollTo({ top: targetScrollTop, behavior: "smooth", }); } private saveScrollPositionOnScroll(aside: HTMLElement) { aside.addEventListener("scroll", () => { sessionStorage.setItem(STORAGE_KEY, aside.scrollTop.toString()); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/search-field-focus.ts ================================================ import {DocumentHandler} from "../../document-handler"; const SEARCH_SHORTCUT_KEY = "/"; /** * Document handler that focuses the search field when '/' is pressed. * This is a common UX pattern for documentation sites. */ export class SearchFieldFocus extends DocumentHandler { async onPostRendering() { document.addEventListener("keydown", (event) => { if (event.key !== SEARCH_SHORTCUT_KEY) return; // Don't intercept if already typing in an input or textarea. const activeElement = document.activeElement; if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) { return; } event.preventDefault(); document.getElementById("search-input")?.focus(); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/search-field.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {createSearch, DocumentSearch, DocumentSearchResult} from "../../../search/search"; import {expandResult} from "../../../search/search-result-expander"; import {renderResultItems} from "../../../search/search-result-renderer"; import {getMetaContent, getRootPath} from "../../../util/meta"; const SEARCH_INPUT_ID = "search-input"; const SEARCH_RESULTS_ID = "search-results"; const SEARCH_INDEX_META_NAME = "quarkdown:search-index"; const DEBOUNCE_MS = 150; const MAX_RESULTS = 10; /** * Document handler that initializes search functionality for documentation sites. * Fetches the search index from the meta tag and displays results in a dropdown. */ export class SearchField extends DocumentHandler { private search: DocumentSearch | null = null; private input: HTMLInputElement | null = null; private resultsContainer: HTMLElement | null = null; private debounceTimeout: ReturnType<typeof setTimeout> | null = null; private selectedIndex = -1; async onPostRendering() { this.input = document.getElementById(SEARCH_INPUT_ID) as HTMLInputElement | null; if (!this.input) return; const indexPath = this.getSearchIndexPath(); if (!indexPath) return; await this.initializeSearch(indexPath); this.createResultsContainer(); this.bindEvents(); } /** * Retrieves the search index path from the meta tag. * @returns The path to the search index JSON, or null if not found */ private getSearchIndexPath(): string | null { return getMetaContent(SEARCH_INDEX_META_NAME); } /** * Fetches and initializes the search index from the given path. * @param indexPath - URL path to the search index JSON file */ private async initializeSearch(indexPath: string): Promise<void> { const response = await fetch(indexPath); if (!response.ok) return; const index = await response.json(); this.search = createSearch(index, {maxResults: MAX_RESULTS}); } /** * Creates the results dropdown container and appends it to the search wrapper. */ private createResultsContainer(): void { const wrapper = this.input!.closest(".search-wrapper"); if (!wrapper) return; this.resultsContainer = document.createElement("div"); this.resultsContainer.id = SEARCH_RESULTS_ID; this.resultsContainer.setAttribute("role", "listbox"); this.resultsContainer.hidden = true; wrapper.appendChild(this.resultsContainer); } /** * Binds event listeners for search input, keyboard navigation, and blur handling. */ private bindEvents(): void { this.input!.addEventListener("input", () => this.onInputChange()); this.input!.addEventListener("keydown", (e) => this.onKeyDown(e)); this.input!.addEventListener("blur", () => this.hideResultsDelayed()); this.resultsContainer!.addEventListener("mousedown", (e) => e.preventDefault()); } /** * Handles input changes with debouncing to avoid excessive searches. */ private onInputChange(): void { if (this.debounceTimeout) clearTimeout(this.debounceTimeout); this.debounceTimeout = setTimeout(() => this.performSearch(), DEBOUNCE_MS); } /** * Executes the search query and renders the results. */ private performSearch(): void { const query = this.input!.value.trim(); if (!query || !this.search) { this.hideResults(); return; } const results = this.search.search(query); this.renderResults(results); } /** * Renders search results into the dropdown container. * @param results - Array of search results to display */ private renderResults(results: DocumentSearchResult[]): void { if (results.length === 0) { this.hideResults(); return; } const displayItems = results .flatMap((result) => expandResult(result)) .map((item) => ({...item, url: this.resolveUrl(item.url)})); this.selectedIndex = -1; this.resultsContainer!.innerHTML = renderResultItems(displayItems); this.resultsContainer!.hidden = false; } /** * Resolves a URL to be relative to the current page's parent directory. * URLs starting with '/' are converted to relative paths. * @param url - The URL to resolve * @returns The resolved relative URL */ private resolveUrl(url: string): string { if (!url.startsWith("/")) return url; return getRootPath() + url; } /** * Handles keyboard navigation within the search results. * @param event - The keyboard event */ private onKeyDown(event: KeyboardEvent): void { if (event.key === "Escape") { this.hideResults(); this.input!.blur(); return; } if (this.resultsContainer!.hidden) return; const items = this.resultsContainer!.querySelectorAll<HTMLElement>(".search-result"); if (items.length === 0) return; const lastIndex = items.length - 1; switch (event.key) { case "ArrowDown": event.preventDefault(); this.selectItem(items, this.selectedIndex < lastIndex ? this.selectedIndex + 1 : 0); break; case "ArrowUp": event.preventDefault(); this.selectItem(items, this.selectedIndex > 0 ? this.selectedIndex - 1 : lastIndex); break; case "Enter": if (this.selectedIndex >= 0) { event.preventDefault(); items[this.selectedIndex].click(); } break; } } /** * Updates the selected item in the results list. * @param items - The list of result elements * @param index - The index to select */ private selectItem(items: NodeListOf<HTMLElement>, index: number): void { items.forEach((item, i) => item.classList.toggle("selected", i === index)); this.selectedIndex = index; items[index].scrollIntoView({block: "nearest"}); } /** * Hides the results dropdown and resets selection. */ private hideResults(): void { this.resultsContainer!.hidden = true; this.selectedIndex = -1; } /** * Hides results after a short delay to allow click events to fire. */ private hideResultsDelayed(): void { setTimeout(() => this.hideResults(), 150); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/sibling-pages-buttons.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {PageListAnalyzer} from "./util/page-list-analyzer"; const BUTTON_AREA_ID = "sibling-pages-button-area"; const PREVIOUS_LINK_ID = "previous-page-anchor"; const NEXT_LINK_ID = "next-page-anchor"; const PREVIOUS_ICON_CLASS = "bi bi-arrow-left"; const NEXT_ICON_CLASS = "bi bi-arrow-right"; /** * Document handler that creates previous/next page navigation links. * Uses PageListAnalyzer to find sibling pages and adds styled links * to the #sibling-pages-button-area element. */ export class SiblingPagesButtons extends DocumentHandler { async onPostRendering() { const buttonArea = document.getElementById(BUTTON_AREA_ID); if (!buttonArea) return; const analyzer = new PageListAnalyzer(); const previousLink = analyzer.getPreviousPageLink(); if (previousLink) { buttonArea.appendChild(this.createAnchor(previousLink, PREVIOUS_LINK_ID, PREVIOUS_ICON_CLASS, "start")); } const nextLink = analyzer.getNextPageLink(); if (nextLink) { buttonArea.appendChild(this.createAnchor(nextLink, NEXT_LINK_ID, NEXT_ICON_CLASS, "end")); } } /** * Creates a cloned anchor element with an icon. * @param anchor - The anchor element to clone * @param anchorId - The ID to assign to the anchor * @param iconClass - The Bootstrap icon class * @param iconPosition - Where to place the icon ("start" or "end") */ private createAnchor( anchor: HTMLAnchorElement, anchorId: string, iconClass: string, iconPosition: "start" | "end", ): HTMLAnchorElement { const clonedAnchor = anchor.cloneNode(true) as HTMLAnchorElement; clonedAnchor.id = anchorId; const icon = document.createElement("i"); icon.className = iconClass; if (iconPosition === "start") { clonedAnchor.insertBefore(icon, clonedAnchor.firstChild); } else { clonedAnchor.appendChild(icon); } return clonedAnchor; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/toc-active-tracking.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {initNavigationActiveTracking} from "../../../navigation/active-tracking"; const TOC_SELECTOR = 'aside nav[data-role="table-of-contents"]'; /** * Document handler that highlights the table of contents entry * corresponding to the currently visible section, using scroll-based tracking. */ export class TocActiveTracking extends DocumentHandler { async onPostRendering() { const toc = document.querySelector<HTMLElement>(TOC_SELECTOR); if (!toc) return; initNavigationActiveTracking(toc); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/util/__tests__/page-list-analyzer.spec.ts ================================================ import {beforeEach, describe, expect, it} from 'vitest'; import {PageListAnalyzer} from "../page-list-analyzer"; describe('PageListAnalyzer', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('returns null when nav does not exist', () => { const analyzer = new PageListAnalyzer(); expect(analyzer.getNextPageLink()).toBeNull(); expect(analyzer.getPreviousPageLink()).toBeNull(); }); it('returns null when there is no current page', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b">B</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getNextPageLink()).toBeNull(); expect(analyzer.getPreviousPageLink()).toBeNull(); }); it('returns null for previous when current is first', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a" aria-current="page">A</a></li> <li><a href="/b">B</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()).toBeNull(); expect(analyzer.getNextPageLink()?.textContent).toBe('B'); }); it('returns null for next when current is last', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()).toBeNull(); }); it('returns previous and next links in flat list', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li><a href="/b" aria-current="page">B</a></li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()?.textContent).toBe('C'); }); it('handles nested lists - previous in parent list', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li> <a href="/a">A</a> <ol> <li><a href="/b" aria-current="page">B</a></li> </ol> </li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()?.textContent).toBe('C'); }); it('handles nested lists - next in child list', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li> <a href="/a" aria-current="page">A</a> <ol> <li><a href="/b">B</a></li> </ol> </li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()).toBeNull(); expect(analyzer.getNextPageLink()?.textContent).toBe('B'); }); it('handles deeply nested lists', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li> <a href="/a">A</a> <ol> <li><a href="/b">B</a></li> </ol> </li> <li><a href="/c" aria-current="page">C</a></li> <li><a href="/d">D</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('B'); expect(analyzer.getNextPageLink()?.textContent).toBe('D'); }); it('excludes hash-only anchor links', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li> <a href="/b" aria-current="page">B</a> <ol> <li><a href="#section1">Section 1</a></li> <li><a href="#section2">Section 2</a></li> </ol> </li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()?.textContent).toBe('C'); }); it('excludes same-page anchors with pathname', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li> <a href="/b" aria-current="page">B</a> <ol> <li><a href="/b#section1">Section 1</a></li> </ol> </li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()?.textContent).toBe('C'); }); it('excludes anchors matching current page href', () => { document.body.innerHTML = ` <nav data-role="page-list"> <ol> <li><a href="/a">A</a></li> <li> <a href="page-b.html" aria-current="page">B</a> <ol> <li><a href="page-b.html#section1">Section 1</a></li> <li><a href="page-b.html#section2">Section 2</a></li> </ol> </li> <li><a href="/c">C</a></li> </ol> </nav>`; const analyzer = new PageListAnalyzer(); expect(analyzer.getPreviousPageLink()?.textContent).toBe('A'); expect(analyzer.getNextPageLink()?.textContent).toBe('C'); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/docs/util/page-list-analyzer.ts ================================================ const PAGE_LIST_NAV_SELECTOR = 'nav[data-role="page-list"]'; const CURRENT_PAGE_SELECTOR = 'a[aria-current="page"]'; /** * Analyzes a page list navigation element to find previous and next page links. * The page list is a possibly nested ordered list where the current page is marked * with `aria-current="page"`. */ export class PageListAnalyzer { private readonly nav: HTMLElement | null; private readonly currentPageAnchor: HTMLAnchorElement | null; constructor() { this.nav = document.querySelector<HTMLElement>(PAGE_LIST_NAV_SELECTOR); this.currentPageAnchor = this.nav?.querySelector<HTMLAnchorElement>(CURRENT_PAGE_SELECTOR) ?? null; } /** * Gets all anchor elements within the page list in document order, * excluding anchor links within the current page. */ private getAllLinks(): HTMLAnchorElement[] { if (!this.nav) return []; return Array.from(this.nav.querySelectorAll<HTMLAnchorElement>("a")) .filter((link) => !this.isSamePageAnchor(link)); } /** * Checks if a link is an anchor within the current page. */ private isSamePageAnchor(link: HTMLAnchorElement): boolean { const href = link.getAttribute("href"); if (!href || href.startsWith("#")) return true; if (link.pathname === location.pathname && link.hash !== "") return true; const currentPageHref = this.currentPageAnchor?.getAttribute("href"); return !!(currentPageHref && href.startsWith(currentPageHref + "#")); } /** * Finds the index of the current page link within all links. */ private getCurrentIndex(links: HTMLAnchorElement[]): number | null { if (!this.currentPageAnchor) return null; const index = links.indexOf(this.currentPageAnchor); return index === -1 ? null : index; } /** * Returns the next page link, or null if there is no next page. */ getNextPageLink(): HTMLAnchorElement | null { const links = this.getAllLinks(); const currentIndex = this.getCurrentIndex(links); if (currentIndex === null || currentIndex >= links.length - 1) return null; return links[currentIndex + 1]; } /** * Returns the previous page link, or null if there is no previous page. */ getPreviousPageLink(): HTMLAnchorElement | null { const links = this.getAllLinks(); const currentIndex = this.getCurrentIndex(links); if (currentIndex === null || currentIndex === 0) return null; return links[currentIndex - 1]; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/footnotes/footnotes-docs.ts ================================================ import {FootnotesDocumentHandler} from "./footnotes-document-handler"; /** * Footnote handler for docs documents that renders footnotes at the bottom of the page. */ export class FootnotesDocs extends FootnotesDocumentHandler { async onPostRendering() { const footnoteArea = document.getElementById('footnote-area'); if (!footnoteArea) return; if (this.footnotes.length === 0) { footnoteArea.remove(); return; } this.footnotes.forEach(({definition}) => { definition.remove(); footnoteArea.appendChild(definition); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/footnotes/footnotes-document-handler.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {FootnotePair} from "../../../footnotes/footnote-pair"; import {getFootnoteDefinitionsAndFirstReference} from "../../../footnotes/footnote-lookup"; /** * Abstract base class for document handlers that work with footnotes. * Automatically collects footnote pairs during pre-rendering phase. */ export abstract class FootnotesDocumentHandler extends DocumentHandler { /** Footnote pairs (reference + definition) collected during pre-rendering. */ protected footnotes: FootnotePair[] = []; async onPreRendering() { this.footnotes = getFootnoteDefinitionsAndFirstReference(); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/footnotes/footnotes-paged.ts ================================================ import {getOrCreateFootnoteRule} from "../../../footnotes/footnote-dom"; import {FootnotesDocumentHandler} from "./footnotes-document-handler"; /** * Footnote handler for paged documents that renders footnotes in a dedicated footnote area on each page. */ export class FootnotesPaged extends FootnotesDocumentHandler { /** * This is a hacky workaround for the base paged.js behavior: * Any change made after the pagination is done will not be processed by paged.js, * hence adding new content (footnotes) will cause content to overflow. * * This function takes all footnote references and creates a virtual empty space * of the size of the footnote definition, reserving space for it. * After rendering, `handleFootnotes` will remove this space and place * the footnote definition in the footnote area, balancing the layout. */ async onPreRendering() { await super.onPreRendering(); this.footnotes.forEach(({reference, definition}) =>{ reference.style.display = 'block'; reference.style.height = definition.scrollHeight + 'px'; // Moves the footnote definition out of the page, to keep the layout intact. definition.remove(); document.body.appendChild(definition); }); } /** * Moves footnote definitions to their respective footnote areas, * and adjusts the layout accordingly. * * Useful context: https://github.com/pagedjs/pagedjs/issues/292 */ async onPostRendering() { await super.onPreRendering(); // Reloads footnotes pairs, since the DOM changed due to paged.js processing. this.footnotes.forEach(({reference, definition}) => { const pageArea = this.quarkdownDocument.getParentViewport(reference); console.log(document) if (!pageArea) return; const footnoteArea = pageArea.querySelector<HTMLElement>('.pagedjs_footnote_area > .pagedjs_footnote_content'); if (!footnoteArea) return; const footnoteContent = footnoteArea.querySelector<HTMLElement>('.pagedjs_footnote_inner_content'); if (!footnoteContent) return; // Moves the footnote definition to the footnote area. definition.remove(); footnoteContent.appendChild(definition); footnoteArea.classList.remove('pagedjs_footnote_empty'); footnoteContent.style.columnWidth = 'auto'; pageArea.style.setProperty('--pagedjs-footnotes-height', `${footnoteArea.scrollHeight}px`); // Resets the temp properties set in pre-rendering. reference.style.height = 'auto'; reference.style.display = 'inline'; getOrCreateFootnoteRule(footnoteContent); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/footnotes/footnotes-plain.ts ================================================ import * as plain from "../../type/plain-document"; import {FootnotesDocumentHandler} from "./footnotes-document-handler"; /** * Footnote handler for plain documents that renders footnotes in the right margin area. * Positions footnotes vertically aligned with their references. */ export class FootnotesPlain extends FootnotesDocumentHandler { /** Sets up listener to re-render footnotes on resize. */ init() { window.addEventListener('resize', () => this.onPostRendering?.()); } /** * Calculates the bottom offset of the last definition in the margin area. * @param marginArea - The margin area containing footnote definitions * @returns The bottom offset in pixels, or the top of the margin area if empty */ private getLastDefinitionOffset(marginArea: HTMLElement): number { const lastChild = marginArea.lastElementChild; return lastChild ? lastChild.getBoundingClientRect().bottom : marginArea.getBoundingClientRect().top; } /** * Renders footnotes in the right margin area, positioned to align with their references. * Removes footnotes from their original locations and repositions them in the margin. */ async onPostRendering() { const rightMarginArea = plain.getRightMarginArea(); if (!rightMarginArea) return; rightMarginArea.innerHTML = ''; this.footnotes.forEach(({reference, definition}) => { definition.remove(); definition.style.marginTop = Math.max( 0, reference.getBoundingClientRect().top - this.getLastDefinitionOffset(rightMarginArea) ) + 'px'; rightMarginArea.appendChild(definition); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/footnotes/footnotes-slides.ts ================================================ import {getOrCreateFootnoteArea} from "../../../footnotes/footnote-dom"; import {FootnotesDocumentHandler} from "./footnotes-document-handler"; /** * Footnote handler for slides documents that renders footnotes at the bottom of the slide. */ export class FootnotesSlides extends FootnotesDocumentHandler { async onPostRendering() { this.footnotes.forEach(({reference, definition}) => { const page = this.quarkdownDocument.getParentViewport(reference); if (!page) return; const footnoteAreaParent = page.classList.contains('pdf-page') ? page.querySelector('section')! : page; definition.remove(); getOrCreateFootnoteArea(footnoteAreaParent)?.appendChild(definition); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/inline-collapsibles.ts ================================================ import {DocumentHandler} from "../document-handler"; /** * Handler for inline collapsible text elements, which toggle between * a collapsed and expanded state when clicked. * * Inline collapsibles are generated by Quarkdown's `.textcollapse` and by error messages. */ export class InlineCollapsibles extends DocumentHandler { async onPostRendering() { // Add click event listener to the collapsible spans. const collapsibles = document.querySelectorAll<HTMLElement>('.inline-collapse'); collapsibles.forEach((span) => { span.addEventListener('click', () => this.toggleCollapse(span)); }); } private toggleCollapse(span: HTMLElement) { const fullText = span.dataset.fullText; const collapsedText = span.dataset.collapsedText; const collapsed = span.dataset.collapsed === 'true'; // Toggle between the full and collapsed text. const content = collapsed ? fullText : collapsedText; if (!content) return; span.dataset.collapsed = (!collapsed).toString(); const isUserDefined = span.closest('.error') === null; if (isUserDefined) { span.innerHTML = content; } else { span.textContent = content; } } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/landscape-size-swapper.ts ================================================ import {DocumentHandler} from "../document-handler"; /** * Handler that swaps the width and height of elements with the `landscape` class. * This is useful for displaying landscape-oriented content in a portrait-oriented layout. * * Landscape elements are generated by Quarkdown's `.landscape` function. */ export class LandscapeSizeSwapper extends DocumentHandler { async onPreRendering() { const landscapeElements = document.querySelectorAll<HTMLElement>('.landscape'); landscapeElements.forEach(element => { const width = element.clientWidth; const height = element.getBoundingClientRect().height; element.style.width = `${height}px`; element.style.height = `${width}px`; }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/page-margins/page-margins-docs.ts ================================================ import {DocumentHandler} from "../../document-handler"; // Base selectors const HEADER = 'body > header'; const CONTENT = 'body > .content-wrapper'; const SIDEBAR_LEFT = `${CONTENT} > aside:first-child`; const SIDEBAR_RIGHT = `${CONTENT} > aside:last-child`; const FOOTER = `${CONTENT} > main > footer`; /** * Maps margin position names to their target container selectors. */ export const MARGIN_TARGETS: Record<string, string> = { // Header 'top-left-corner': `${HEADER} > aside:first-child`, 'top-left': `${HEADER} > aside:first-child`, 'top-center': `${HEADER} > main`, 'top-right-corner': `${HEADER} > aside:last-child`, 'top-right': `${HEADER} > aside:last-child`, // Left sidebar 'left-top': `${SIDEBAR_LEFT} > .position-top`, 'left-middle': `${SIDEBAR_LEFT} > .position-middle`, 'left-bottom': `${SIDEBAR_LEFT} > .position-bottom`, 'bottom-left-corner': `${SIDEBAR_LEFT} > .position-bottom`, // Right sidebar 'right-top': `${SIDEBAR_RIGHT} > .position-top`, 'right-middle': `${SIDEBAR_RIGHT} > .position-middle`, 'right-bottom': `${SIDEBAR_RIGHT} > .position-bottom`, 'bottom-right-corner': `${SIDEBAR_RIGHT} > .position-bottom`, // Footer 'bottom-left': `${FOOTER} > .position-left`, 'bottom-center': `${FOOTER} > .position-center`, 'bottom-right': `${FOOTER} > .position-right`, }; /** * Page margins handler for documentation sites. * Unlike paged/slides implementations, docs have fixed containers without pagination. * This handler moves margin content into predefined containers in the document structure. */ export class PageMarginsDocs extends DocumentHandler { async onPostRendering(): Promise<void> { document.querySelectorAll<HTMLElement>('.page-margin-content').forEach((initializer) => { const position = this.getMarginPosition(initializer); if (!position) return; initializer.remove(); const selector = MARGIN_TARGETS[position]; if (!selector) return; const container = document.querySelector(selector); if (!container) return; container.appendChild(this.createWrapper(initializer, position)); }); } /** * Creates a wrapper element with the appropriate classes and content. */ private createWrapper(initializer: HTMLElement, position: string): HTMLElement { const wrapper = document.createElement('div'); wrapper.classList.add(`page-margin-${position}`, ...initializer.classList); wrapper.innerHTML = initializer.innerHTML; return wrapper; } /** * Gets the margin position name from the initializer element. * Docs don't have left/right pages, so either data attribute works. */ private getMarginPosition(initializer: HTMLElement): string | null { return initializer.dataset.onLeftPage ?? initializer.dataset.onRightPage ?? null; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/page-margins/page-margins-document-handler.ts ================================================ import {DocumentHandler} from "../../document-handler"; import {PagedLikeQuarkdownDocument, QuarkdownPage} from "../../paged-like-quarkdown-document"; /** * Abstract base class for document handlers that manage page margin content. * Collects page margin initializers during pre-rendering and positions them appropriately * on each page in the final document based on the document type. * * In case of `outside`/`inside` positions, the `data-on-left-page` and `data-on-right-page` may differ. */ export abstract class PageMarginsDocumentHandler extends DocumentHandler<PagedLikeQuarkdownDocument<any>> { /** * @param page The page or element to get initializers from * @return An array of page margin initializer elements within the given page */ private selectPageMarginInitializers(page: QuarkdownPage | HTMLElement): HTMLElement[] { return Array.from(page.querySelectorAll('.page-margin-content')); } /** * Collects all page margin content initializers and hides them from the document. * This prevents them from being displayed before proper positioning. */ async onPreRendering() { this.selectPageMarginInitializers(document.body) .forEach(initializer => { initializer.setAttribute('data-hidden', 'true'); initializer.style.display = 'none'; }); } /** * Called after the main rendering process is complete, * this function is responsible for injecting page margin content * into the document at appropriate locations on each page. * * It processes each page, and stores active margin initializers. * Since #281, a page margin begins appearing from the page where the initializer is defined, * and continues to appear on subsequent pages unless overridden. */ async onPostRendering() { const activeByPosition = new Map<string, HTMLElement>(); this.quarkdownDocument.getPages().forEach(page => { // The initializers defined on this page. const localInitializers = this.selectPageMarginInitializers(page); // Update active initializers for this page. localInitializers.forEach(initializer => { activeByPosition.set(initializer.className, initializer); initializer.remove(); }); // Apply all active initializers to this page. activeByPosition.forEach((initializer: HTMLElement) => { const marginPositionName = this.getMarginPositionName(initializer, page); if (marginPositionName) { this.apply(initializer, page, marginPositionName); } }); }); } /** * Gets the margin position name for the given page margin initializer, depending on whether the page is left or right. * @param initializer The page margin initializer element * @param page The page the margin will be applied to * @return The margin position name (e.g., "top-left", "bottom-center"), if defined */ private getMarginPositionName(initializer: HTMLElement, page: QuarkdownPage): string | null { const pageType = this.quarkdownDocument.getPageType(page); return initializer.getAttribute(`data-on-${pageType}-page`); } /** * Copies the class list from the initializer to the target margin element, * adding the specific margin position class. * @param target The target margin element to which classes will be added * @param initializer The page margin initializer element * @param marginPositionName The margin position name (e.g., "top-left", "bottom-center") */ protected pushMarginClassList(target: HTMLElement, initializer: HTMLElement, marginPositionName: string) { target.classList.add( `page-margin-${marginPositionName}`, // In case of mirror positions (outside/inside), sets the actual position. ...initializer.classList, ); } /** * Applies the page margin initializer to the given page. * This method must be implemented by subclasses to define * how the margin content is injected into the page. * @param initializer The page margin initializer element * @param page The page to apply the margin to * @param marginPositionName The margin position name (e.g., "top-left", "bottom-center") */ abstract apply(initializer: HTMLElement, page: QuarkdownPage, marginPositionName: string): void; } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/page-margins/page-margins-paged.ts ================================================ import {PageMarginsDocumentHandler} from "./page-margins-document-handler"; /** * Page margins handler for paged documents, which copies page margin content to each page. * * @example * A page margin initializer like: * ```html * <div class="page-margin-content-initializer page-margin-bottom-center" * data-on-left-page="bottom-center" data-on-right-page="bottom-center">Hello</div> * ``` * * will be copied to each section background as: * * ```html * <div class="pagedjs_margin-content">Hello</div> * ``` * * contained in the `.pagedjs_margin.pagedjs_margin-bottom-center` div. */ export class PageMarginsPaged extends PageMarginsDocumentHandler { apply(initializer: HTMLElement, page: HTMLElement, marginPositionName: string): void { // Given the initializer with class "page-margin-content-initializer page-margin-bottom-center", // the margin class will be "pagedjs_margin-bottom-center". const pageMargins = page.querySelectorAll(`.pagedjs_margin-${marginPositionName}`); pageMargins.forEach(pageMargin => { pageMargin.classList.add("hasContent"); // Required by paged.js to show the content. // Find the container where the content should go. const container = pageMargin.querySelector<HTMLElement>(".pagedjs_margin-content"); if (!container) return; // Copy the classes, allows for styling. this.pushMarginClassList(container, initializer, marginPositionName); // Copy the content of the initializer to each page. container.innerHTML = initializer.innerHTML; }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/page-margins/page-margins-slides.ts ================================================ import {PageMarginsDocumentHandler} from "./page-margins-document-handler"; import {SlidesPage} from "../../type/slides-document"; /** * Page margins handler for slides documents, which copies page margin content to all slide backgrounds. * * @example * A page margin initializer like: * ```html * <div class="page-margin-content-initializer page-margin-bottom-center">Hello</div> * ``` * * will be copied to each section background as: * * ```html * <div class="page-margin-content page-margin-bottom-center">Hello</div> * ``` */ export class PageMarginsSlides extends PageMarginsDocumentHandler { /** * Copies all page margin initializers to the slide background. */ apply(initializer: HTMLElement, page: SlidesPage, marginPositionName: string) { // Append the page margin to all slide backgrounds. const pageMargin = document.createElement('div'); this.pushMarginClassList(pageMargin, initializer, marginPositionName); pageMargin.innerHTML = initializer.innerHTML; page.background.appendChild(pageMargin); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/page-numbers.ts ================================================ import {DocumentHandler} from "../document-handler"; import {PagedLikeQuarkdownDocument, QuarkdownPage} from "../paged-like-quarkdown-document"; import {getAnchorTargetId} from "../../util/id"; import {formatNumber} from "../../util/numbering"; /** * Abstract base class for document handlers that manage page numbering. * Provides utility methods to find and update page number elements in documents, * including support for page number resets and displaying page numbers in tables of contents. */ export class PageNumbers extends DocumentHandler<PagedLikeQuarkdownDocument<any>> { /** * Gets all elements that display the total page count. * @returns NodeList of total page number elements (`.total-page-number`) */ private getTotalPageNumberElements(): NodeListOf<HTMLElement> { return document.querySelectorAll<HTMLElement>('.total-page-number'); } /** * Gets all elements that display the current page number. * @param page - The page element to search within * @returns NodeList of current page number elements (`.current-page-number`) */ private getCurrentPageNumberElements(page: QuarkdownPage): NodeListOf<HTMLElement> { return page.querySelectorAll('.current-page-number'); } /** * Finds all page number reset markers contained in the given page. */ private getPageNumberResetMarkers(page: QuarkdownPage): HTMLElement[] { return Array.from(page.querySelectorAll('.page-number-reset')); } /** * Finds all page number format markers contained in the given page. */ private getPageNumberFormatMarkers(page: QuarkdownPage): HTMLElement[] { return Array.from(page.querySelectorAll('.page-number-formatter')); } /** * Updates all total page number elements with the total count of pages. */ private updateTotalPageNumbers(pages: QuarkdownPage[]) { const amount = pages.length; this.getTotalPageNumberElements().forEach(total => { total.innerText = amount.toString(); }); } /** * Updates all current page number elements with their respective (possibly reset) page numbers. */ private updateCurrentPageNumbers(pages: QuarkdownPage[]) { let pageNumber = 1; let currentFormat = "1"; pages.forEach(page => { // Checking for format markers on the current page. The last format marker on the page determines the format for the page number. const formatMarkers = this.getPageNumberFormatMarkers(page); formatMarkers.forEach(marker => { const format = marker.dataset.format; if (format !== undefined) { currentFormat = format; } }); // Checking for reset markers on the current page. In that case, the page number is directly updated. const resetMarkers = this.getPageNumberResetMarkers(page); resetMarkers.forEach(marker => { const requested = parseInt(marker.dataset.start || '1', 10); if (Number.isFinite(requested) && requested > 0) { pageNumber = requested; } }); const formattedPageNumber = formatNumber(pageNumber, currentFormat); this.quarkdownDocument.setDisplayPageNumber(page, formattedPageNumber); // Applying the page number within the page. this.getCurrentPageNumberElements(page).forEach(pageNumberElement => { pageNumberElement.innerText = formattedPageNumber; }); pageNumber += 1; }); } /** * Updates table of contents entries so they display the logical (reset-aware) page numbers. */ private updateTableOfContentsPageNumbers() { const tocs = document.querySelectorAll<HTMLElement>('nav[data-role="table-of-contents"]'); tocs.forEach(nav => { nav.querySelectorAll<HTMLAnchorElement>(':scope a[href^="#"]').forEach(anchor => { const targetId = getAnchorTargetId(anchor); const target = targetId ? document.getElementById(targetId) : undefined; const displayNumber = target ? this.quarkdownDocument.getDisplayPageNumber(this.quarkdownDocument.getPage(target)) : undefined; this.setTableOfContentsPageNumber(anchor, displayNumber?.toString()); }); }); } /** * Sets or updates the page number badge within a table of contents entry. * @param anchor - The anchor element representing the TOC entry * @param value - The page number to set (if undefined, the badge will be created but left empty) */ private setTableOfContentsPageNumber(anchor: HTMLAnchorElement, value?: string) { let badge = anchor.querySelector<HTMLElement>('.toc-page-number'); if (!badge) { badge = document.createElement('span'); badge.className = 'toc-page-number'; anchor.appendChild(badge); } if (value) { badge.innerText = value; } } /** * Updates both total and current page numbers after rendering completes. */ async onPostRendering() { const pages = this.quarkdownDocument.getPages(); this.updateTotalPageNumbers(pages); this.updateCurrentPageNumbers(pages); this.updateTableOfContentsPageNumbers(); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/paged/split-code-blocks-fix-paged.ts ================================================ import {DocumentHandler} from "../../document-handler"; /** * Represents a pair of code blocks where one was split from the other due to page breaks. */ interface SplitCodeBlock { /** The original code block that was split */ from: HTMLElement; /** The new code block created from the split */ split: HTMLElement; } /** * Document handler that fixes issues with code blocks that have been split across page breaks. * * When code blocks are split due to page breaks in paged media, several issues can occur: * - The split code block loses proper indentation on its first line * - Line numbers restart from 1 instead of continuing from the original block * * This handler identifies split code blocks using `data-split-from` and `data-ref` attributes * and corrects these formatting issues. */ export class SplitCodeBlocksFixPaged extends DocumentHandler { /** * Identifies and returns all code blocks that were split due to page breaks. * * Split code blocks are identified by the presence of a `data-split-from` attribute, * which contains the `data-ref` value of the original code block they were split from. * * @returns An array of split code block pairs, each containing the original block and its split counterpart */ private getSplitCodeBlocks(): SplitCodeBlock[] { const splitCodeBlocks: SplitCodeBlock[] = []; // Splits code blocks have the attribute `data-split-from`, where its value // is the `data-ref` attribute of the code block it was split from. document.querySelectorAll<HTMLElement>('code[data-split-from]').forEach(split => { const fromRef = split.getAttribute('data-split-from'); if (!fromRef) return splitCodeBlocks; const from = document.querySelector<HTMLElement>(`code[data-ref="${fromRef}"]`); if (!from) return splitCodeBlocks; splitCodeBlocks.push({from, split}); }); return splitCodeBlocks; } /** * Fixes the indentation of the first line in split code blocks. * * When a code block is split, the first line of the split portion often loses * its proper indentation. This method extracts the indentation from the last * line of the original code block and applies it to the split block. * * @param splitCodeBlocks Array of split code block pairs to fix */ private fixSplitCodeBlockFirstLineIndentation(splitCodeBlocks: SplitCodeBlock[]) { splitCodeBlocks.forEach(({from, split}) => { // The indentation of the first line is contained in the last line of the original code block. const fromLastLine = from.innerText.split('\n').pop(); if (!fromLastLine) return; const indentation = fromLastLine.match(/\s*$/)?.[0] || ''; split.innerHTML = indentation + split.innerHTML; }) } /** * Corrects line numbers in split code blocks to continue from the original block. * * Split code blocks typically restart their line numbering from 1, but they should * continue the numbering sequence from where the original block left off. This method * finds the last line number in the original block and adjusts all line numbers * in the split block accordingly. * * @param splitCodeBlocks Array of split code block pairs to fix */ private fixSplitCodeBlockLineNumbers(splitCodeBlocks: SplitCodeBlock[]) { const lineNumberAttribute = 'data-line-number'; splitCodeBlocks.forEach(({from, split}) => { const lines = from.querySelectorAll(`[${lineNumberAttribute}]`); const lastLineNumber = Array.from(lines).pop()?.getAttribute(lineNumberAttribute) || '0'; split.querySelectorAll(`[${lineNumberAttribute}]`).forEach(line => { const lineNumber = line.getAttribute(lineNumberAttribute); if (!lineNumber) return; line.setAttribute(lineNumberAttribute, (parseInt(lineNumber) + parseInt(lastLineNumber)).toString()); }); }); } /** * Executes the split code block fixes after document rendering is complete. * * This method is called during the post-rendering phase and: * 1. Identifies all split code blocks in the document * 2. Fixes their line numbering immediately * 3. Schedules another line number fix after syntax highlighting completes * * The setTimeout is necessary because syntax highlighting may modify the DOM * after initial rendering, potentially affecting line number attributes. */ async onPostRendering() { const splitCodeBlocks = this.getSplitCodeBlocks(); this.fixSplitCodeBlockFirstLineIndentation(splitCodeBlocks); setTimeout(() => this.fixSplitCodeBlockLineNumbers(splitCodeBlocks), 0); // Must execute after the highlighting is done. } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/persistent-headings.ts ================================================ import {DocumentHandler} from "../document-handler"; import {PagedLikeQuarkdownDocument, QuarkdownPage} from "../paged-like-quarkdown-document"; const MIN_HEADING_LEVEL = 1; const MAX_HEADING_LEVEL = 6; /** * Handler that manages persistent headings across pages. * Maintains a history of the most recent heading at each depth level and applies * them to elements with the `.last-heading` class. */ export class PersistentHeadings extends DocumentHandler<PagedLikeQuarkdownDocument<any>> { /** * Array storing the most recent heading HTML content at each depth level. * Index 0 corresponds to h1, index 1 to h2, etc. */ protected readonly lastHeadingPerDepth: string[] = []; /** * Scans a page for headings (h1-h6) and updates the internal heading history. * Only the last heading of the highest level found is stored, and lower level headings are cleared. * * @example * If the container has: * ```html * <h2>Title</h2> * <h3>Subtitle</h3> * <h2>Another Title</h2> * ``` * * Then after calling this method, `lastHeadingPerDepth` will be: * ```typescript * ["", "Another Title", "", "", "", ""] // h1 is empty, h2 is "Another Title", h3 has been cleared * ``` * * @param page - The page to scan for headings */ private overwriteLastHeadings(page: QuarkdownPage) { // Find the highest level non-decorative heading in the container (h1 to h6). for (let depth = MIN_HEADING_LEVEL; depth <= MAX_HEADING_LEVEL; depth++) { const headings = page.querySelectorAll(`h${depth}:not([data-decorative])`); if (headings.length > 0) { this.lastHeadingPerDepth[depth - 1] = headings[headings.length - 1].innerHTML; this.lastHeadingPerDepth.length = depth; // Remove lower level headings. } } } /** * Applies the stored heading content to elements with the `.last-heading` class * within the specified containers. The heading content is determined by the * `data-depth` attribute on each `.last-heading` element. * @param page - The page containing `.last-heading` elements to update */ private applyLastHeadings(page: QuarkdownPage) { const lastHeadingElements = page.querySelectorAll('.last-heading'); lastHeadingElements.forEach(lastHeading => { const depth = parseInt(lastHeading.dataset.depth || '0'); lastHeading.innerHTML = this.lastHeadingPerDepth[depth - 1] || ''; }); } async onPostRendering() { const pages = this.quarkdownDocument.getPages(); pages.forEach(page => { this.overwriteLastHeadings(page); this.applyLastHeadings(page); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/remaining-height.ts ================================================ import {DocumentHandler} from "../document-handler"; /** * Handler that calculates the remaining height in the viewport for elements * with the `fill-height` class, and sets a CSS variable `--viewport-remaining-height` * on those elements. This allows such elements to adapt their height based on * the available space in the viewport. */ export class RemainingHeight extends DocumentHandler { async onPostRendering() { const fillHeightElements = document.querySelectorAll<HTMLElement>('.fill-height'); fillHeightElements.forEach(element => { const contentArea = this.quarkdownDocument.getParentViewport(element) if (!contentArea) return; const remainingHeight = contentArea.getBoundingClientRect().bottom - element.getBoundingClientRect().top; // Inject CSS variable. element.style.setProperty('--viewport-remaining-height', `${remainingHeight}px`); }); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/show-on-ready.ts ================================================ import {DocumentHandler} from "../document-handler"; /** * A document handler that hides the body until the document is fully rendered. * This prevents flickering and unfinished content from being visible to the user. */ export class ShowOnReady extends DocumentHandler { async onPreRendering() { document.body.style.opacity = "0"; } async onPostRendering() { document.body.style.opacity = "1"; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/handlers/sidebar.ts ================================================ import {DocumentHandler} from "../document-handler"; import {initNavigationActiveTracking} from "../../navigation/active-tracking"; /** * Document handler responsible for relocating the sidebar from the template * and initializing active state tracking. */ export class Sidebar extends DocumentHandler { async onPostRendering() { const template = document.querySelector<HTMLTemplateElement>('#sidebar-template'); if (!template) return; const sidebar = template.content.firstElementChild?.cloneNode(true) as HTMLElement; if (!sidebar) return; sidebar.style.position = "fixed"; document.body.appendChild(sidebar); template.remove(); initNavigationActiveTracking(sidebar); } } ================================================ FILE: quarkdown-html/src/main/typescript/document/paged-like-quarkdown-document.ts ================================================ import {QuarkdownDocument} from "./quarkdown-document"; /** * A page in a paged-like Quarkdown document. */ export type QuarkdownPage = { querySelectorAll(query: string): NodeListOf<HTMLElement> }; /** * A Quarkdown document that is divided into discrete pages. * @template TPage - The type of page elements in the document */ export interface PagedLikeQuarkdownDocument<TPage extends QuarkdownPage = HTMLElement> extends QuarkdownDocument { /** * Gets all pages in the document. * @returns All page elements. */ getPages(): TPage[]; /** * Gets the page number of the given page. * @param page - The page to get the number for * @returns The page number (1-based) */ getPageNumber(page: TPage): number; /** * Gets the display page number of the given page, which may differ from the physical page number, * for example due to page number resets. * @param page - The page to get the display number for * @returns The display page number (1-based) */ getDisplayPageNumber(page: TPage): string; /** * Sets the display page number for the given page. * The display number is a logical page number that may differ from the physical page number, * for example due to page number resets. * @param page - The page to set the display number for * @param pageNumber - The display page number to set (1-based) */ setDisplayPageNumber(page: TPage, pageNumber: string): void; /** * @param page - The page to get the type for * @returns The page type ('left' or 'right') */ getPageType(page: TPage): 'left' | 'right'; /** * Gets the page that contains the given element. * @param element - The element to find the page for * @returns The containing page, or `undefined` if not found */ getPage(element: HTMLElement): TPage | undefined; } ================================================ FILE: quarkdown-html/src/main/typescript/document/quarkdown-document.ts ================================================ import {ConditionalDocumentHandler, DocumentHandler, filterConditionalHandlers} from "./document-handler"; import {preRenderingExecutionQueue} from "../queue/execution-queues"; import {getGlobalHandlers} from "./global-handlers"; /** * Core interface for Quarkdown document types. * Defines the document rendering lifecycle and management. */ export interface QuarkdownDocument { /** * Gets the parent viewport element for the given element. * For instance, the element's slide in `slides` documents, * or the element's page in `paged` documents. * @param element - The element to find the parent viewport for * @returns The parent viewport element, if any */ getParentViewport(element: Element): HTMLElement | undefined; /** Sets up the hook that executed the pre-rendering queue. */ setupPreRenderingHook(): void; /** Sets up the hook that executes the post-rendering queue. */ setupPostRenderingHook(): void; /** * Initializes the document rendering process. * For instance, Reveal.js inizialization in `slides` documents. */ initializeRendering(): void; /** * @returns Array of document handlers that apply to this document. */ getHandlers(): ConditionalDocumentHandler[] } /** * Prepares a Quarkdown document for rendering by setting up handlers and hooks. * This is called by the HTML wrapper. * @param document - The document to prepare for rendering */ export function prepare(document: QuarkdownDocument): void { const handlers: DocumentHandler[] = filterConditionalHandlers([...document.getHandlers(), ...getGlobalHandlers(document)]); handlers.forEach(handler => handler.pushToQueue()); document.setupPreRenderingHook(); document.setupPostRenderingHook(); preRenderingExecutionQueue.addOnComplete(() => document.initializeRendering()); } ================================================ FILE: quarkdown-html/src/main/typescript/document/type/docs-document.ts ================================================ import {DocumentHandler} from "../document-handler"; import {PlainDocument} from "./plain-document"; import {PageMarginsDocs} from "../handlers/page-margins/page-margins-docs"; import {SearchFieldFocus} from "../handlers/docs/search-field-focus"; import {SearchField} from "../handlers/docs/search-field"; import {FootnotesDocs} from "../handlers/footnotes/footnotes-docs"; import {SiblingPagesButtons} from "../handlers/docs/sibling-pages-buttons"; import {PageListAutoscroll} from "../handlers/docs/page-list-autoscroll"; import {TocActiveTracking} from "../handlers/docs/toc-active-tracking"; /** * 'Docs' document implementation for HTML documents targeting documentation sites and wikis. * This implementation relies on PlainDocument for most functionality. */ export class DocsDocument extends PlainDocument { getHandlers(): DocumentHandler[] { return [ new SearchFieldFocus(this), new SearchField(this), new SiblingPagesButtons(this), new PageMarginsDocs(this), new FootnotesDocs(this), new PageListAutoscroll(this), new TocActiveTracking(this), ]; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/type/paged-document.ts ================================================ import {DocumentHandler} from "../document-handler"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "../../queue/execution-queues"; import {Sidebar} from "../handlers/sidebar"; import {PageMarginsPaged} from "../handlers/page-margins/page-margins-paged"; import {FootnotesPaged} from "../handlers/footnotes/footnotes-paged"; import {SplitCodeBlocksFixPaged} from "../handlers/paged/split-code-blocks-fix-paged"; import {PageNumbers} from "../handlers/page-numbers"; import {PagedLikeQuarkdownDocument} from "../paged-like-quarkdown-document"; import {ShowOnReady} from "../handlers/show-on-ready"; import {PersistentHeadings} from "../handlers/persistent-headings"; declare const Paged: typeof import("pagedjs"); // global Paged at runtime /** * Paged document implementation for paged.js media. */ export class PagedDocument implements PagedLikeQuarkdownDocument { /** * @returns The parent page of the given element. */ getParentViewport(element: Element): HTMLElement | undefined { return element.closest<HTMLElement>('.pagedjs_area') || undefined; } getPages(): HTMLElement[] { return Array.from(document.querySelectorAll<HTMLElement>('.pagedjs_page')); } getPage(element: HTMLElement): HTMLElement | undefined { return element.closest<HTMLElement>('.pagedjs_page') || undefined; } getPageNumber(page: HTMLElement): number { return parseInt(page.dataset.pageNumber ?? "0"); } getDisplayPageNumber(page: HTMLElement): string { return page.dataset.displayPageNumber ?? this.getPageNumber(page).toString(); } setDisplayPageNumber(page: HTMLElement, pageNumber: string) { page.setAttribute("data-display-page-number", pageNumber); } getPageType(page: HTMLElement): "left" | "right" { return page.classList.contains("pagedjs_right_page") ? "right" : "left"; } /** Sets up pre-rendering to execute when DOM content is loaded. */ setupPreRenderingHook() { document.addEventListener("DOMContentLoaded", async () => await preRenderingExecutionQueue.execute()); } /** Sets up post-rendering to execute when paged.js is ready. */ setupPostRenderingHook(): void { class PagedAfterReadyHandler extends Paged.Handler { afterRendered() { postRenderingExecutionQueue.execute().then(); } } Paged.registerHandlers(PagedAfterReadyHandler); } /** Initializes paged.js rendering. */ initializeRendering(): void { (window as any).PagedPolyfill?.preview().then(); } getHandlers(): DocumentHandler[] { return [ new Sidebar(this), new ShowOnReady(this), new PageMarginsPaged(this), new PageNumbers(this), new PersistentHeadings(this), new FootnotesPaged(this), new SplitCodeBlocksFixPaged(this), ]; } } ================================================ FILE: quarkdown-html/src/main/typescript/document/type/plain-document.ts ================================================ import {QuarkdownDocument} from "../quarkdown-document"; import {DocumentHandler} from "../document-handler"; import {Sidebar} from "../handlers/sidebar"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "../../queue/execution-queues"; import {FootnotesPlain} from "../handlers/footnotes/footnotes-plain"; /** * Plain document implementation for standard HTML documents. * Uses the document element as the viewport and executes rendering queues sequentially. */ export class PlainDocument implements QuarkdownDocument { /** * @returns The document element */ getParentViewport(_element: Element): HTMLElement | undefined { return document.documentElement; } /** Sets up pre-rendering to execute when DOM content is loaded */ setupPreRenderingHook() { document.addEventListener("DOMContentLoaded", async () => { await preRenderingExecutionQueue.execute(); }); } /** No post-rendering hook needed for plain documents */ setupPostRenderingHook() { } /** Executes post-rendering queue since pre- and post-rendering overlap for plain documents */ initializeRendering() { postRenderingExecutionQueue.execute().then(); } getHandlers(): DocumentHandler[] { return [ new Sidebar(this), new FootnotesPlain(this), ]; } } /** * Retrieves the right margin area element from the document. * This area is typically used for displaying footnotes or annotations. * @returns The right margin area, if available */ export function getRightMarginArea(): HTMLElement | null { return document.querySelector<HTMLElement>('#margin-area-right'); } ================================================ FILE: quarkdown-html/src/main/typescript/document/type/slides-document.ts ================================================ import {DocumentHandler} from "../document-handler"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "../../queue/execution-queues"; import {PageChunker} from "../../chunker/page-chunker"; import {PageMarginsSlides} from "../handlers/page-margins/page-margins-slides"; import {FootnotesSlides} from "../handlers/footnotes/footnotes-slides"; import {PageNumbers} from "../handlers/page-numbers"; import {PagedLikeQuarkdownDocument, QuarkdownPage} from "../paged-like-quarkdown-document"; import {PersistentHeadings} from "../handlers/persistent-headings"; declare const Reveal: typeof import("reveal.js"); // global Reveal at runtime declare const RevealNotes: typeof import("reveal.js/plugin/notes/notes"); const SLIDE_SELECTOR = ".reveal .slides > :is(section, .pdf-page)"; const BACKGROUND_SELECTOR = ".reveal :is(.backgrounds, .slides > .pdf-page) > .slide-background"; /** * A Reveal.js slide page, consisting of the slide and its background. */ export type SlidesPage = { slide: HTMLElement, background: HTMLElement } & QuarkdownPage; /** * Slides document implementation for Reveal.js presentations. */ export class SlidesDocument implements PagedLikeQuarkdownDocument<SlidesPage> { /** * Retrieves a configuration property from the global configuration (`slidesConfig`). * Configuration is injected by Quarkdown's `.slides` function. */ private getConfigProperty<T>(property: string, defaultValue: T): T { const config = (window as any).slidesConfig || {}; return config[property] ?? defaultValue; } /** * @returns The parent slide element of the given element. */ getParentViewport(element: Element): HTMLElement | undefined { return element.closest<HTMLElement>(SLIDE_SELECTOR) || undefined; } getPages(): SlidesPage[] { const slides = document.querySelectorAll<HTMLElement>(SLIDE_SELECTOR); const backgrounds = document.querySelectorAll<HTMLElement>(BACKGROUND_SELECTOR); if (!slides || !backgrounds) return []; return Array.from(slides).map((slide, index) => { const background = backgrounds[index] return { slide: slide, background: background || document.createElement('div'), // Fallback for missing background querySelectorAll(query: string): NodeListOf<HTMLElement> { const slideResults = slide.querySelectorAll<HTMLElement>(query); const bgResults = background?.querySelectorAll<HTMLElement>(query) || []; return [...slideResults, ...Array.from(bgResults)] as unknown as NodeListOf<HTMLElement>; } }; }); } getPageNumber(page: SlidesPage): number { const slide = page.slide; if (!slide.parentElement) return 0; const index = Array.from(slide.parentElement.children).indexOf(slide) return index + 1; } getDisplayPageNumber(page: SlidesPage): string { const slide = page.slide; const displayNumber = slide.dataset.displayPageNumber; return displayNumber ? displayNumber : this.getPageNumber(page).toString(); } setDisplayPageNumber(page: SlidesPage, pageNumber: string) { page.slide.setAttribute("data-display-page-number", pageNumber); } getPageType(page: SlidesPage): "left" | "right" { const pageNumber = this.getPageNumber(page); return (pageNumber % 2 === 0) ? "left" : "right"; } getPage(element: HTMLElement): SlidesPage | undefined { return this.getPages().find(page => page.slide === this.getParentViewport(element)); } /** Sets up pre-rendering to execute when DOM content is loaded */ setupPreRenderingHook() { document.addEventListener("DOMContentLoaded", async () => await preRenderingExecutionQueue.execute()); } /** Sets up post-rendering to execute when Reveal.js is ready */ setupPostRenderingHook() { Reveal.addEventListener("ready", () => { if ((Reveal as any).isPrintView()) { Reveal.addEventListener("pdf-ready", () => postRenderingExecutionQueue.execute()); } else { postRenderingExecutionQueue.execute().then(); } }); } /** Chunks content into slides and initializes Reveal.js */ initializeRendering() { // Chunk the slides based on page breaks. const slidesDiv = document.querySelector<HTMLElement>('.reveal .slides'); if (!slidesDiv) return; new PageChunker(slidesDiv).chunk(); // Initialize Reveal.js with the updated DOM. Reveal.initialize({ // If the center property is not explicitly set, it defaults to true unless the `--reveal-center-vertically` CSS variable of `:root` is set to `false`. center: this.getConfigProperty( "center", getComputedStyle(document.documentElement).getPropertyValue("--reveal-center-vertically") !== "false" ), controls: this.getConfigProperty("showControls", true), showNotes: this.getConfigProperty("showNotes", false), transition: this.getConfigProperty("transitionStyle", "slide"), transitionSpeed: this.getConfigProperty("transitionSpeed", "default"), hash: true, plugins: [RevealNotes], }).then(); } getHandlers(): DocumentHandler[] { return [ new PageMarginsSlides(this), new PageNumbers(this), new PersistentHeadings(this), new FootnotesSlides(this), ]; } } ================================================ FILE: quarkdown-html/src/main/typescript/footnotes/footnote-dom.ts ================================================ /** * Gets an existing footnote rule or creates a new one within the footnote area. * The footnote rule is a visual separator that appears at the top of the footnote area. * @param footnoteArea - The footnote area element to search within or add the rule to * @returns The existing or newly created footnote rule element */ export function getOrCreateFootnoteRule(footnoteArea: Element) { const footnoteRuleClassName = 'footnote-rule'; const existingRule = footnoteArea.querySelector(`.${footnoteRuleClassName}`); if (existingRule) return existingRule; const rule = document.createElement('div'); rule.className = footnoteRuleClassName; footnoteArea.insertAdjacentElement('afterbegin', rule) return rule; } /** * Gets an existing footnote area or creates a new one within the page. * The footnote area contains all footnotes for a page and includes a footnote rule. * @param page - The page element to search within or add the footnote area to * @returns The existing or newly created footnote area element */ export function getOrCreateFootnoteArea(page: Element) { const className = 'footnote-area'; let footnoteArea = page.querySelector(`.${className}`); if (footnoteArea) return footnoteArea; footnoteArea = document.createElement('div'); footnoteArea.className = className; page.appendChild(footnoteArea); getOrCreateFootnoteRule(footnoteArea); return footnoteArea; } ================================================ FILE: quarkdown-html/src/main/typescript/footnotes/footnote-lookup.ts ================================================ import {FootnotePair} from "./footnote-pair"; /** * Retrieves all footnote definition elements from the document. * @param sorted - Whether to sort definitions by their footnote index * @returns Array of footnote definition HTML elements */ function getFootnoteDefinitions(sorted: boolean): HTMLElement[] { const definitions = Array.from(document.querySelectorAll<HTMLElement>('.footnote-definition')); if (!sorted) { return definitions; } return definitions.sort((a, b) => { const indexA = parseInt(a.dataset.footnoteIndex || '0'); const indexB = parseInt(b.dataset.footnoteIndex || '0'); return indexA - indexB; }); } function getFootnoteFirstReference(definitionId: string): HTMLElement | null { return document.querySelector<HTMLElement>(`.footnote-reference[data-definition="${definitionId}"]`); } /** * Creates footnote pairs by matching definitions with their first references. * @param sorted - Whether to sort definitions by footnote index (default: true) * @returns Array of footnote pairs, linking definitions to their first references */ export function getFootnoteDefinitionsAndFirstReference(sorted: boolean = true): FootnotePair[] { const definitions: HTMLElement[] = getFootnoteDefinitions(sorted); // For each definition, gets the first reference to it. return definitions.map(definition => { const reference = getFootnoteFirstReference(definition.id); return reference ? {reference, definition} : null; }).filter(item => item !== null); } ================================================ FILE: quarkdown-html/src/main/typescript/footnotes/footnote-pair.ts ================================================ /** * A pair of footnote reference and definition elements. */ export interface FootnotePair { /** The footnote reference element. */ readonly reference: HTMLElement; /** The footnote definition element. */ readonly definition: HTMLElement; } ================================================ FILE: quarkdown-html/src/main/typescript/index.ts ================================================ // Main entry point for the quarkdown-html runtime. import {capabilities} from "./capabilities"; import {postRenderingExecutionQueue, preRenderingExecutionQueue} from "./queue/execution-queues"; import {PlainDocument} from "./document/type/plain-document"; import {prepare} from "./document/quarkdown-document"; import {notifyLivePreview} from "./live/live-preview"; import {SlidesDocument} from "./document/type/slides-document"; import {PagedDocument} from "./document/type/paged-document"; import {DocsDocument} from "./document/type/docs-document"; /** * Returns whether the document is finalized and ready. * This can be watched and waited for by other tools, such as Puppeteer to generate a PDF. * @returns {boolean} */ function isReady(): boolean { return preRenderingExecutionQueue.isCompleted() && postRenderingExecutionQueue.isCompleted(); } // Notify the live preview that the document is ready after pre-rendering tasks are done. postRenderingExecutionQueue.addOnComplete(() => notifyLivePreview('postRenderingCompleted')); // Expose the API to the global context. const context = window as any; context.isReady = isReady; context.quarkdownCapabilities = capabilities; context.prepare = prepare; context.PlainDocument = PlainDocument; context.PagedDocument = PagedDocument; context.SlidesDocument = SlidesDocument; context.DocsDocument = DocsDocument; ================================================ FILE: quarkdown-html/src/main/typescript/live/live-preview.ts ================================================ const MESSAGE_SOURCE = 'quarkdown'; const TARGET_ORIGIN = '*'; /** * Notify the parent window (if exists) about an event in the live preview. * This is used to communicate with the Quarkdown editor. * @param event The event name * @param data Additional data to send with the event */ export function notifyLivePreview(event: string, data: Record<string, any> = {}) { if (!window.parent || window.parent === window) return; try { window.parent.postMessage( { source: MESSAGE_SOURCE, event, data, timestamp: Date.now() }, TARGET_ORIGIN, ); } catch (e) { console.error('Failed to post message to parent', e); } } ================================================ FILE: quarkdown-html/src/main/typescript/navigation/active-tracking.ts ================================================ /** * Initializes active state tracking for navigation items. * Uses IntersectionObserver for efficient scroll-based highlighting. * * @param navigation - The navigation element containing items with `data-target-id` attributes */ export function initNavigationActiveTracking(navigation: HTMLElement): void { const items = navigation.querySelectorAll<HTMLLIElement>('li[data-target-id]'); if (items.length === 0) return; // Map target IDs to their corresponding navigation items const targetToItem = new Map<string, HTMLLIElement>(); items.forEach(item => { const targetId = item.dataset.targetId; if (targetId) { targetToItem.set(targetId, item); } }); let currentActiveItem: HTMLLIElement | null = null; // Track which headings are currently visible and their positions const visibleHeadings = new Map<string, number>(); const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { const id = entry.target.id; if (entry.isIntersecting) { visibleHeadings.set(id, entry.boundingClientRect.top); } else { visibleHeadings.delete(id); } }); updateActiveItem(); }, { rootMargin: '-10% 0px -60% 0px', threshold: 0, } ); function updateActiveItem(): void { // Find the topmost visible heading let topmostId: string | null = null; let topmostPosition = Infinity; visibleHeadings.forEach((position, id) => { if (position < topmostPosition) { topmostPosition = position; topmostId = id; } }); // If no visible headings, keep the last active or find the one above viewport if (!topmostId && currentActiveItem) { return; } const newActiveItem = topmostId ? targetToItem.get(topmostId) : null; if (newActiveItem && newActiveItem !== currentActiveItem) { currentActiveItem?.classList.remove('active'); newActiveItem.classList.add('active'); currentActiveItem = newActiveItem; } } // Observe all target headings targetToItem.forEach((_, targetId) => { const heading = document.getElementById(targetId); if (heading) { observer.observe(heading); } }); // Initial update based on scroll position requestAnimationFrame(() => { // Find heading closest to top of viewport for initial state let closestItem: HTMLLIElement | undefined; let closestDistance = Infinity; for (const [targetId, item] of targetToItem) { const heading = document.getElementById(targetId); if (heading) { const rect = heading.getBoundingClientRect(); const distance = Math.abs(rect.top); if (rect.top <= window.innerHeight * 0.4 && distance < closestDistance) { closestDistance = distance; closestItem = item; } } } if (closestItem) { closestItem.classList.add('active'); currentActiveItem = closestItem; } }); } ================================================ FILE: quarkdown-html/src/main/typescript/queue/async-execution-queue.ts ================================================ /** * A queue for managing and executing asynchronous functions in parallel. * * This class allows you to collect multiple asynchronous functions and execute them * all at once using Promise.all(). It provides a way to track completion status * and execute a callback after all queued functions have completed. * * @example * ```typescript * const queue = new AsyncExecutionQueue(() => console.log('All done!')); * * queue.push(async () => { await ... }); * queue.push(async () => { await ... }); * * await queue.execute(); // Executes all functions in parallel * console.log(queue.isCompleted()); // true * ``` */ export class AsyncExecutionQueue { /** Array of async functions waiting to be executed */ private queue: Array<() => Promise<void>> = []; /** Callback function executed after all queued functions complete */ private onComplete: Array<() => void> = []; /** Flag indicating whether the queue has been executed and completed */ private completed: boolean = false; /** * Adds an asynchronous function to the execution queue. * * @param fn - An async function that returns a Promise<void> to be executed later */ pushAsync(fn: () => Promise<void>) { this.queue.push(fn); } /** * Adds a synchronous function to the execution queue. * * This method wraps the provided synchronous function in an async function * that returns a resolved Promise, allowing it to be executed in the same * manner as other async functions in the queue. * * @param fn - A synchronous function to be executed later */ push(fn: () => void) { this.queue.push(async () => fn()); } /** * Registers a callback to be called after all queued functions have executed. * * @param fn - A function to be called once after `execute()` completes */ addOnComplete(fn: () => void) { this.onComplete.push(fn); } /** * Executes all queued functions in parallel and clears the queue. * * This method uses Promise.all() to run all queued functions concurrently, * waits for all of them to complete, then clears the queue and calls the * onExecute callback. After execution, the queue is marked as completed. * * @returns A Promise that resolves when all queued functions have completed */ async execute(): Promise<void> { await Promise.all(this.queue.map(async fn => fn())); this.queue = []; this.onComplete?.forEach(fn => fn()); this.completed = true; } /** * Checks whether the queue has been executed and completed. * * @returns true if `execute()` has been called and completed, false otherwise */ isCompleted(): boolean { return this.completed; } } ================================================ FILE: quarkdown-html/src/main/typescript/queue/execution-queues.ts ================================================ // Queue of actions to be executed before the document is handled by Reveal/Paged. // The document is elaborated only after this queue is executed. import {AsyncExecutionQueue} from "./async-execution-queue"; // Queue of actions to be executed before the document is handled by the document type's specific framework. export const preRenderingExecutionQueue = new AsyncExecutionQueue(); // Queue of actions to be executed after the document has been rendered in its final form. export const postRenderingExecutionQueue = new AsyncExecutionQueue(); ================================================ FILE: quarkdown-html/src/main/typescript/search/__tests__/search-highlight.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {extractPreviewAroundMatch, highlightTerms, trimTitleFromContent,} from "../search-highlight"; describe("trimTitleFromContent", () => { it("removes title from start of content", () => { expect(trimTitleFromContent("Hello World\nThis is content", "Hello World")).toBe( "This is content" ); }); it("is case insensitive", () => { expect(trimTitleFromContent("HELLO WORLD\nContent", "hello world")).toBe("Content"); }); it("handles leading whitespace", () => { expect(trimTitleFromContent(" Title\nContent", "Title")).toBe("Content"); }); it("returns content unchanged if title not at start", () => { expect(trimTitleFromContent("Some other content", "Title")).toBe("Some other content"); }); it("returns content if title is null", () => { expect(trimTitleFromContent("Content", null)).toBe("Content"); }); it("returns content if content is empty", () => { expect(trimTitleFromContent("", "Title")).toBe(""); }); }); describe("extractPreviewAroundMatch", () => { it("returns full content if shorter than max length", () => { expect(extractPreviewAroundMatch("Short content", ["term"], 300)).toBe("Short content"); }); it("returns beginning with ellipsis if no match found", () => { const longContent = "A".repeat(400); const result = extractPreviewAroundMatch(longContent, ["xyz"], 100); expect(result).toBe("A".repeat(100) + "…"); }); it("centers preview around first match", () => { const content = "A".repeat(200) + "MATCH" + "B".repeat(200); const result = extractPreviewAroundMatch(content, ["match"], 50); expect(result).toContain("MATCH"); expect(result.startsWith("…")).toBe(true); expect(result.endsWith("…")).toBe(true); }); it("adds ellipsis at start when not at beginning", () => { const content = "A".repeat(100) + "MATCH" + "B".repeat(50); const result = extractPreviewAroundMatch(content, ["match"], 100); expect(result.startsWith("…")).toBe(true); }); it("adds ellipsis at end when not at end", () => { const content = "MATCH" + "A".repeat(200); const result = extractPreviewAroundMatch(content, ["match"], 100); expect(result.endsWith("…")).toBe(true); }); it("finds earliest match when multiple terms", () => { const content = "First APPLE then BANANA"; const result = extractPreviewAroundMatch(content, ["banana", "apple"], 300); expect(result).toBe("First APPLE then BANANA"); }); }); describe("highlightTerms", () => { it("wraps matched terms in strong tags", () => { expect(highlightTerms("Hello world", ["world"])).toBe("Hello <strong>world</strong>"); }); it("is case insensitive", () => { expect(highlightTerms("Hello WORLD", ["world"])).toBe("Hello <strong>WORLD</strong>"); }); it("highlights multiple occurrences", () => { expect(highlightTerms("foo bar foo", ["foo"])).toBe( "<strong>foo</strong> bar <strong>foo</strong>" ); }); it("highlights multiple different terms", () => { expect(highlightTerms("foo bar baz", ["foo", "baz"])).toBe( "<strong>foo</strong> bar <strong>baz</strong>" ); }); it("escapes HTML in non-matched parts", () => { expect(highlightTerms("<b>foo</b> bar", ["bar"])).toBe( "&lt;b&gt;foo&lt;/b&gt; <strong>bar</strong>" ); }); it("escapes HTML in matched parts", () => { expect(highlightTerms("<script>", ["<script>"])).toBe( "<strong>&lt;script&gt;</strong>" ); }); it("returns escaped text if no terms", () => { expect(highlightTerms("<div>text</div>", [])).toBe("&lt;div&gt;text&lt;/div&gt;"); }); it("handles longer terms first to avoid partial matches", () => { expect(highlightTerms("javascript is great", ["java", "javascript"])).toBe( "<strong>javascript</strong> is great" ); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/search/__tests__/search-result-expander.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {expandResult} from "../search-result-expander"; import {DocumentSearchResult} from "../search"; function createResult(overrides: Partial<DocumentSearchResult> = {}): DocumentSearchResult { return { entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Page content here", headings: [], }, score: 10, matchedTerms: ["term"], matchedFields: {term: ["content"]}, ...overrides, }; } describe("expandResult", () => { it("returns main page result", () => { const result = createResult(); const items = expandResult(result); expect(items).toHaveLength(1); expect(items[0].url).toBe("/docs/page"); expect(items[0].title).toBe("Page Title"); expect(items[0].parentTitle).toBeUndefined(); }); it("uses url as title when title is null", () => { const result = createResult({ entry: { url: "/docs/page", title: null, description: null, keywords: [], content: "Content", headings: [], }, }); const items = expandResult(result); expect(items[0].title).toBe("/docs/page"); }); it("adds heading results when headings field matched", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content", headings: [ {anchor: "intro", text: "Introduction", level: 2}, {anchor: "setup", text: "Setup Guide", level: 2}, ], }, matchedTerms: ["intro"], matchedFields: {intro: ["headings"]}, }); const items = expandResult(result); expect(items).toHaveLength(2); expect(items[0].url).toBe("/docs/page"); expect(items[1].url).toBe("/docs/page#intro"); expect(items[1].title).toBe("Introduction"); expect(items[1].parentTitle).toBe("Page Title"); }); it("adds multiple heading results for multiple matches", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content", headings: [ {anchor: "intro", text: "Introduction", level: 2}, {anchor: "setup", text: "Setup Guide", level: 2}, {anchor: "config", text: "Configuration", level: 3}, ], }, matchedTerms: ["intro", "config"], matchedFields: {intro: ["headings"], config: ["headings"]}, }); const items = expandResult(result); expect(items).toHaveLength(3); expect(items[1].url).toBe("/docs/page#intro"); expect(items[2].url).toBe("/docs/page#config"); }); it("excludes headings that match document title", () => { const result = createResult({ entry: { url: "/docs/page", title: "Introduction", description: null, keywords: [], content: "Content", headings: [ {anchor: "intro", text: "Introduction", level: 1}, {anchor: "setup", text: "Setup Guide", level: 2}, ], }, matchedTerms: ["intro"], matchedFields: {intro: ["headings"]}, }); const items = expandResult(result); expect(items).toHaveLength(1); expect(items[0].title).toBe("Introduction"); expect(items[0].parentTitle).toBeUndefined(); }); it("uses description for preview when available", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: "This is the description", keywords: [], content: "This is the content", headings: [], }, matchedTerms: ["description"], matchedFields: {description: ["description"]}, }); const items = expandResult(result); expect(items[0].description).toContain("description"); }); it("does not highlight description when match is from title", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content with title word", headings: [], }, matchedTerms: ["title"], matchedFields: {title: ["title"]}, }); const items = expandResult(result); expect(items[0].description).not.toContain("<strong>"); }); it("does not highlight description when match is from headings", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content with intro word", headings: [{anchor: "intro", text: "Introduction", level: 2}], }, matchedTerms: ["intro"], matchedFields: {intro: ["headings"]}, }); const items = expandResult(result); expect(items[0].description).not.toContain("<strong>"); }); it("highlights description when match is from content", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content with searchterm here", headings: [], }, matchedTerms: ["searchterm"], matchedFields: {searchterm: ["content"]}, }); const items = expandResult(result); expect(items[0].description).toContain("<strong>searchterm</strong>"); }); it("escapes HTML in descriptions", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "<div>some content</div>", headings: [], }, matchedTerms: ["content"], matchedFields: {content: ["content"]}, }); const items = expandResult(result); expect(items[0].description).toContain("&lt;div&gt;"); expect(items[0].description).not.toContain("<div>"); }); it("heading results have empty description", () => { const result = createResult({ entry: { url: "/docs/page", title: "Page Title", description: null, keywords: [], content: "Content", headings: [{anchor: "intro", text: "Introduction", level: 2}], }, matchedTerms: ["intro"], matchedFields: {intro: ["headings"]}, }); const items = expandResult(result); expect(items[1].description).toBe(""); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/search/__tests__/search-result-renderer.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {renderResultItem, renderResultItems} from "../search-result-renderer"; import {DisplayItem} from "../search-result-expander"; describe("renderResultItem", () => { it("renders basic result item", () => { const item: DisplayItem = { url: "/docs/page", title: "Page Title", description: "Description text", }; const html = renderResultItem(item, 0); expect(html).toContain('href="/docs/page"'); expect(html).toContain('class="search-result"'); expect(html).toContain("Page Title"); expect(html).toContain("Description text"); expect(html).toContain('data-index="0"'); expect(html).toContain('role="option"'); }); it("renders heading result with parent title and chevron", () => { const item: DisplayItem = { url: "/docs/page#section", title: "Section Title", description: "", parentTitle: "Page Title", }; const html = renderResultItem(item, 1); expect(html).toContain('class="search-result search-result-heading"'); expect(html).toContain("Page Title"); expect(html).toContain('class="search-result-chevron"'); expect(html).toContain("Section Title"); expect(html).toContain('data-index="1"'); }); it("omits description div when description is empty", () => { const item: DisplayItem = { url: "/docs/page", title: "Page Title", description: "", }; const html = renderResultItem(item, 0); expect(html).not.toContain("search-result-description"); }); it("includes description div when description has content", () => { const item: DisplayItem = { url: "/docs/page", title: "Page Title", description: "Some description", }; const html = renderResultItem(item, 0); expect(html).toContain('class="search-result-description"'); expect(html).toContain("Some description"); }); it("escapes HTML in title", () => { const item: DisplayItem = { url: "/docs/page", title: "<script>alert('xss')</script>", description: "", }; const html = renderResultItem(item, 0); expect(html).toContain("&lt;script&gt;"); expect(html).not.toContain("<script>alert"); }); it("escapes HTML in URL", () => { const item: DisplayItem = { url: "/docs/page?q=<script>", title: "Page", description: "", }; const html = renderResultItem(item, 0); expect(html).toContain("&lt;script&gt;"); expect(html).not.toContain('href="/docs/page?q=<script>"'); }); it("escapes HTML in parent title", () => { const item: DisplayItem = { url: "/docs/page#section", title: "Section", description: "", parentTitle: "<b>Parent</b>", }; const html = renderResultItem(item, 0); expect(html).toContain("&lt;b&gt;Parent&lt;/b&gt;"); }); }); describe("renderResultItems", () => { it("renders empty string for empty array", () => { expect(renderResultItems([])).toBe(""); }); it("renders multiple items with correct indices", () => { const items: DisplayItem[] = [ {url: "/a", title: "A", description: ""}, {url: "/b", title: "B", description: ""}, {url: "/c", title: "C", description: ""}, ]; const html = renderResultItems(items); expect(html).toContain('data-index="0"'); expect(html).toContain('data-index="1"'); expect(html).toContain('data-index="2"'); }); it("concatenates all item HTML", () => { const items: DisplayItem[] = [ {url: "/first", title: "First", description: ""}, {url: "/second", title: "Second", description: "Desc"}, ]; const html = renderResultItems(items); expect(html).toContain("First"); expect(html).toContain("Second"); expect(html).toContain("Desc"); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/search/__tests__/search.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {createSearch, DocumentSearch, SearchIndex} from "../search"; const testIndex: SearchIndex = { entries: [ { url: "/", title: "Home", description: "Welcome to the documentation", keywords: ["home", "welcome"], content: "This is the home page with some content.", headings: [], }, { url: "/getting-started", title: "Getting Started", description: "Learn how to get started", keywords: ["tutorial", "beginner"], content: "Follow these steps to begin using the application.", headings: [ { anchor: "installation", text: "Installation", level: 1 }, { anchor: "configuration", text: "Configuration", level: 2 }, ], }, { url: "/api", title: "API Reference", description: "Complete API documentation", keywords: ["api", "reference", "methods"], content: "The API provides methods for interacting with the system.", headings: [ { anchor: "methods", text: "Methods", level: 1 }, { anchor: "parameters", text: "Parameters", level: 2 }, ], }, { url: "/faq", title: "FAQ", description: null, keywords: [], content: "Frequently asked questions about the application.", headings: [], }, ], }; describe("DocumentSearch", () => { it("indexes entries correctly", () => { const search = new DocumentSearch(testIndex); expect(search.entryCount).toBe(4); }); it("returns empty array for empty query", () => { const search = new DocumentSearch(testIndex); expect(search.search("")).toEqual([]); expect(search.search(" ")).toEqual([]); }); it("finds exact matches", () => { const search = new DocumentSearch(testIndex); const results = search.search("API"); expect(results.length).toBeGreaterThan(0); expect(results[0].entry.url).toBe("/api"); expect(results[0].matchedTerms).toContain("api"); }); it("performs fuzzy matching", () => { const search = new DocumentSearch(testIndex); // "instalation" (misspelled) should still find "Installation" const results = search.search("instalation"); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.entry.url === "/getting-started")).toBe(true); }); it("performs prefix matching", () => { const search = new DocumentSearch(testIndex); // "config" should match "Configuration" const results = search.search("config"); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.entry.url === "/getting-started")).toBe(true); }); it("boosts title matches higher than content", () => { const search = new DocumentSearch(testIndex); // "FAQ" appears in title of /faq and as "frequently asked questions" in content const results = search.search("FAQ"); expect(results.length).toBeGreaterThan(0); expect(results[0].entry.url).toBe("/faq"); }); it("searches in headings", () => { const search = new DocumentSearch(testIndex); const results = search.search("Parameters"); expect(results.length).toBeGreaterThan(0); expect(results[0].entry.url).toBe("/api"); }); it("searches in keywords", () => { const search = new DocumentSearch(testIndex); const results = search.search("beginner"); expect(results.length).toBeGreaterThan(0); expect(results[0].entry.url).toBe("/getting-started"); }); it("searches in description", () => { const search = new DocumentSearch(testIndex); const results = search.search("documentation"); expect(results.length).toBeGreaterThan(0); const urls = results.map((r) => r.entry.url); expect(urls).toContain("/"); expect(urls).toContain("/api"); }); it("respects maxResults option", () => { const search = new DocumentSearch(testIndex, { maxResults: 2 }); const results = search.search("the"); expect(results.length).toBeLessThanOrEqual(2); }); it("returns results with scores", () => { const search = new DocumentSearch(testIndex); const results = search.search("API"); expect(results[0].score).toBeGreaterThan(0); }); it("returns results sorted by score descending", () => { const search = new DocumentSearch(testIndex); const results = search.search("application"); for (let i = 1; i < results.length; i++) { expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score); } }); }); describe("DocumentSearch.suggest", () => { it("returns empty array for empty query", () => { const search = new DocumentSearch(testIndex); expect(search.suggest("")).toEqual([]); expect(search.suggest(" ")).toEqual([]); }); it("returns suggestions for partial input", () => { const search = new DocumentSearch(testIndex); const suggestions = search.suggest("app"); expect(suggestions.length).toBeGreaterThan(0); }); it("respects maxSuggestions parameter", () => { const search = new DocumentSearch(testIndex); const suggestions = search.suggest("a", 2); expect(suggestions.length).toBeLessThanOrEqual(2); }); }); describe("createSearch", () => { it("creates search from object", () => { const search = createSearch(testIndex); expect(search.entryCount).toBe(4); }); it("creates search from JSON string", () => { const search = createSearch(JSON.stringify(testIndex)); expect(search.entryCount).toBe(4); }); it("accepts custom options", () => { const search = createSearch(testIndex, { maxResults: 1 }); const results = search.search("the"); expect(results.length).toBeLessThanOrEqual(1); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/search/search-highlight.ts ================================================ /** * Utilities for highlighting search terms in text previews. */ import {escapeHtml, escapeRegExp} from "../util/escape"; export {escapeHtml}; /** * Removes the title from the beginning of the content, if present. * This handles the case where plain text extraction includes the H1 title. * @param content - The full content text * @param title - The entry title to trim * @returns Content with title trimmed from the start */ export function trimTitleFromContent(content: string, title: string | null): string { if (!title || !content) return content; const trimmedContent = content.trimStart(); if (trimmedContent.toLowerCase().startsWith(title.toLowerCase())) { return trimmedContent.slice(title.length).trimStart(); } return content; } /** * Extracts a preview of the content centered around the first match. * @param content - The full content text * @param matchedTerms - Array of matched search terms * @param maxLength - Maximum length of the preview * @returns A truncated preview with ellipsis if needed */ export function extractPreviewAroundMatch( content: string, matchedTerms: string[], maxLength: number = 300 ): string { if (content.length <= maxLength) return content; // Find the first matching term in the content const lowerContent = content.toLowerCase(); let firstMatchIndex = -1; for (const term of matchedTerms) { const index = lowerContent.indexOf(term.toLowerCase()); if (index !== -1 && (firstMatchIndex === -1 || index < firstMatchIndex)) { firstMatchIndex = index; } } // If no match found, return from the beginning if (firstMatchIndex === -1) { return content.slice(0, maxLength).trimEnd() + "…"; } // Center the preview around the match const halfLength = Math.floor(maxLength / 2); let start = Math.max(0, firstMatchIndex - halfLength); let end = Math.min(content.length, start + maxLength); // Adjust start if we're near the end if (end === content.length) { start = Math.max(0, end - maxLength); } let preview = content.slice(start, end); // Add ellipsis if (start > 0) preview = "…" + preview.trimStart(); if (end < content.length) preview = preview.trimEnd() + "…"; return preview; } /** * Wraps matched terms in the text with strong tags for highlighting. * @param text - The text to process * @param matchedTerms - Array of terms to highlight * @returns HTML string with matches wrapped in strong tags */ export function highlightTerms(text: string, matchedTerms: string[]): string { if (matchedTerms.length === 0) return escapeHtml(text); // Sort terms by length (longest first) to avoid partial replacements const sortedTerms = [...matchedTerms].sort((a, b) => b.length - a.length); const pattern = new RegExp(`(${sortedTerms.map((t) => escapeRegExp(t)).join("|")})`, "gi"); // Split by matches and process each part const parts = text.split(pattern); return parts .map((part) => { const isMatch = sortedTerms.some((term) => part.toLowerCase() === term.toLowerCase()); const escaped = escapeHtml(part); return isMatch ? `<strong>${escaped}</strong>` : escaped; }) .join(""); } ================================================ FILE: quarkdown-html/src/main/typescript/search/search-result-expander.ts ================================================ import {DocumentSearchResult} from "./search"; import {escapeHtml, extractPreviewAroundMatch, highlightTerms, trimTitleFromContent,} from "./search-highlight"; /** * A display item for rendering in the search results dropdown. */ export interface DisplayItem { url: string; title: string; description: string; /** For heading results: the parent page title */ parentTitle?: string; } /** * Expands a search result into display items, creating separate items for heading matches. * @param result - The search result to expand * @returns Array of display items */ export function expandResult(result: DocumentSearchResult): DisplayItem[] { const {entry, matchedFields} = result; const items: DisplayItem[] = []; // Add the main page result items.push({ url: entry.url, title: entry.title ?? entry.url, description: getHighlightedDescription(result), }); // Add separate items for heading matches const headingTerms = getTermsForField(matchedFields, "headings"); if (headingTerms.length > 0) { const matchingHeadings = findMatchingHeadings(entry.headings, headingTerms, entry.title); for (const heading of matchingHeadings) { items.push({ url: `${entry.url}#${heading.anchor}`, title: heading.text, description: "", parentTitle: entry.title ?? entry.url, }); } } return items; } /** * Gets the terms that matched in a specific field. * @param matchedFields - Map of terms to fields they matched in * @param field - The field to get terms for * @returns Array of terms that matched in the specified field */ function getTermsForField(matchedFields: Record<string, string[]>, field: string): string[] { return Object.entries(matchedFields) .filter(([, fields]) => fields.includes(field)) .map(([term]) => term); } /** * Finds headings that match any of the search terms, excluding those that match the document title. * @param headings - Array of headings to search * @param terms - Array of terms to match against * @param title - The document title to exclude from results * @returns Headings that contain any of the terms */ function findMatchingHeadings( headings: DocumentSearchResult["entry"]["headings"], terms: string[], title: string | null ): DocumentSearchResult["entry"]["headings"] { const normalizedTitle = title?.toLowerCase(); return headings.filter( (heading) => heading.text.toLowerCase() !== normalizedTitle && terms.some((term) => heading.text.toLowerCase().includes(term.toLowerCase())) ); } /** * Checks if any match came from the title or headings. * @param matchedFields - Map of terms to fields they matched in * @returns True if any match is from the document title or headings */ function isTitleOrHeadingMatch(matchedFields: Record<string, string[]>): boolean { const flattened = Object.values(matchedFields).flat(); return flattened.includes("title") || flattened.includes("headings"); } /** * Generates a highlighted description for a search result. * @param result - The search result * @returns HTML string with highlighted matches */ function getHighlightedDescription(result: DocumentSearchResult): string { const {entry, matchedTerms, matchedFields} = result; const text = entry.description ?? trimTitleFromContent(entry.content, entry.title); if (!text) return ""; // If match is from the title or headings, don't highlight anything if (isTitleOrHeadingMatch(matchedFields)) { const preview = extractPreviewAroundMatch(text, []); return escapeHtml(preview); } const preview = extractPreviewAroundMatch(text, matchedTerms); return highlightTerms(preview, matchedTerms); } ================================================ FILE: quarkdown-html/src/main/typescript/search/search-result-renderer.ts ================================================ import {DisplayItem} from "./search-result-expander"; import {escapeHtml} from "./search-highlight"; /** * Renders a single search result item as an HTML string. * @param item - The display item to render * @param index - The index of the result for keyboard navigation * @returns HTML string for the result item */ export function renderResultItem(item: DisplayItem, index: number): string { const className = item.parentTitle ? "search-result search-result-heading" : "search-result"; const titleHtml = item.parentTitle ? `${escapeHtml(item.parentTitle)}<span class="search-result-chevron"></span>${escapeHtml(item.title)}` : escapeHtml(item.title); return `<a href="${escapeHtml(item.url)}" class="${className}" role="option" data-index="${index}"> <div class="search-result-title">${titleHtml}</div> ${item.description ? `<div class="search-result-description">${item.description}</div>` : ""} </a>`; } /** * Renders an array of display items into HTML. * @param items - Array of display items to render * @returns Combined HTML string for all items */ export function renderResultItems(items: DisplayItem[]): string { return items.map((item, index) => renderResultItem(item, index)).join(""); } ================================================ FILE: quarkdown-html/src/main/typescript/search/search.ts ================================================ import MiniSearch, {SearchOptions, SearchResult as MiniSearchResult} from "minisearch"; /** * A heading within a search index entry. */ export interface SearchHeading { anchor: string; text: string; level: number; } /** * A single entry in the search index. */ export interface SearchEntry { url: string; title: string | null; description: string | null; keywords: string[]; content: string; headings: SearchHeading[]; } /** * The search index structure as generated by Quarkdown. */ export interface SearchIndex { entries: SearchEntry[]; } /** * Internal document structure used for indexing. */ interface IndexedDocument { id: number; url: string; title: string; description: string; keywords: string; content: string; headings: string; } /** * A single search result. */ export interface DocumentSearchResult { entry: SearchEntry; score: number; matchedTerms: string[]; /** Map of matched terms to the fields they were found in */ matchedFields: Record<string, string[]>; } /** * Options for the document search engine. */ export interface DocumentSearchOptions { /** Enable fuzzy matching */ fuzzy?: boolean | number; /** Enable prefix matching */ prefix?: boolean; /** Boost factor for title matches */ titleBoost?: number; /** Boost factor for heading matches */ headingsBoost?: number; /** Boost factor for keywords matches */ keywordsBoost?: number; /** Boost factor for description matches */ descriptionBoost?: number; /** Boost factor for content matches */ contentBoost?: number; /** Maximum number of results to return */ maxResults?: number; } const DEFAULT_OPTIONS: Required<Omit<DocumentSearchOptions, "maxResults">> = { fuzzy: 0.2, prefix: true, titleBoost: 3, headingsBoost: 2, keywordsBoost: 2.5, descriptionBoost: 1.5, contentBoost: 1, }; /** * A DOM-independent search engine for Quarkdown documents. * Uses MiniSearch for fuzzy full-text search. */ export class DocumentSearch { private miniSearch: MiniSearch<IndexedDocument>; private entriesMap: Map<number, SearchEntry>; private options: Required<Omit<DocumentSearchOptions, "maxResults">> & Pick<DocumentSearchOptions, "maxResults">; /** * Creates a new DocumentSearch instance. * * @param index - The search index to use * @param options - Search configuration options */ constructor(index: SearchIndex, options: DocumentSearchOptions = {}) { this.options = { ...DEFAULT_OPTIONS, ...options }; this.entriesMap = new Map(); this.miniSearch = this.createMiniSearch(); this.indexEntries(index.entries); } private createMiniSearch(): MiniSearch<IndexedDocument> { return new MiniSearch<IndexedDocument>({ fields: ["title", "description", "keywords", "content", "headings"], storeFields: ["url"], searchOptions: { boost: { title: this.options.titleBoost, headings: this.options.headingsBoost, keywords: this.options.keywordsBoost, description: this.options.descriptionBoost, content: this.options.contentBoost, }, fuzzy: this.options.fuzzy, prefix: this.options.prefix, }, }); } private indexEntries(entries: SearchEntry[]): void { const documents: IndexedDocument[] = entries.map((entry, index) => { this.entriesMap.set(index, entry); return { id: index, url: entry.url, title: entry.title ?? "", description: entry.description ?? "", keywords: entry.keywords.join(" "), content: entry.content, headings: entry.headings.map((h) => h.text).join(" "), }; }); this.miniSearch.addAll(documents); } /** * Performs a fuzzy search on the index. * * @param query - The search query string * @param searchOptions - Optional per-search options to override defaults * @returns An array of search results sorted by relevance score */ search(query: string, searchOptions?: SearchOptions): DocumentSearchResult[] { if (!query.trim()) { return []; } const results: MiniSearchResult[] = this.miniSearch.search(query, searchOptions); const seenUrls = new Set<string>(); const mappedResults: DocumentSearchResult[] = results .map((result) => { const entry = this.entriesMap.get(result.id as number); if (!entry) return null; if (seenUrls.has(entry.url)) return null; seenUrls.add(entry.url); return { entry, score: result.score, matchedTerms: result.terms, matchedFields: result.match, }; }) .filter((result): result is DocumentSearchResult => result !== null); if (this.options.maxResults !== undefined) { return mappedResults.slice(0, this.options.maxResults); } return mappedResults; } /** * Gets autocomplete suggestions for a partial query. * * @param query - The partial search query * @param maxSuggestions - Maximum number of suggestions to return. Default: 5 * @returns An array of suggested search terms */ suggest(query: string, maxSuggestions: number = 5): string[] { if (!query.trim()) { return []; } return this.miniSearch .autoSuggest(query, { fuzzy: this.options.fuzzy, prefix: this.options.prefix }) .slice(0, maxSuggestions) .map((suggestion) => suggestion.suggestion); } /** * Returns the number of indexed entries. */ get entryCount(): number { return this.miniSearch.documentCount; } } /** * Creates a DocumentSearch instance from a search index. * * @param index - The search index object or JSON string * @param options - Search configuration options * @returns A configured DocumentSearch instance */ export function createSearch(index: SearchIndex | string, options?: DocumentSearchOptions): DocumentSearch { const parsedIndex: SearchIndex = typeof index === "string" ? JSON.parse(index) : index; return new DocumentSearch(parsedIndex, options); } ================================================ FILE: quarkdown-html/src/main/typescript/types/pagedjs.d.ts ================================================ /** Type definitions for pagedjs */ declare module "pagedjs" { export class Handler { afterRendered(): void; } export function registerHandlers(handler: typeof Handler): void; const Paged: { Handler: typeof Handler; registerHandlers: typeof registerHandlers; }; export default Paged; } ================================================ FILE: quarkdown-html/src/main/typescript/util/__tests__/escape.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {escapeHtml, escapeRegExp} from "../escape"; describe("escapeHtml", () => { it("escapes HTML special characters", () => { expect(escapeHtml("<script>alert('xss')</script>")).toBe( "&lt;script&gt;alert('xss')&lt;/script&gt;" ); }); it("escapes ampersands", () => { expect(escapeHtml("foo & bar")).toBe("foo &amp; bar"); }); it("preserves quotes (DOM-based escaping)", () => { // DOM-based escaping via textContent/innerHTML doesn't escape quotes expect(escapeHtml('"quoted"')).toBe('"quoted"'); }); it("returns empty string for empty input", () => { expect(escapeHtml("")).toBe(""); }); it("leaves plain text unchanged", () => { expect(escapeHtml("Hello World")).toBe("Hello World"); }); }); describe("escapeRegExp", () => { it("escapes special regex characters", () => { expect(escapeRegExp("foo.*bar")).toBe("foo\\.\\*bar"); }); it("escapes parentheses and brackets", () => { expect(escapeRegExp("(test)[0]")).toBe("\\(test\\)\\[0\\]"); }); it("escapes question marks and plus signs", () => { expect(escapeRegExp("a+b?c")).toBe("a\\+b\\?c"); }); it("escapes dollar and caret", () => { expect(escapeRegExp("^start$end")).toBe("\\^start\\$end"); }); it("escapes curly braces and pipe", () => { expect(escapeRegExp("{a|b}")).toBe("\\{a\\|b\\}"); }); it("escapes backslashes", () => { expect(escapeRegExp("path\\to\\file")).toBe("path\\\\to\\\\file"); }); it("leaves plain text unchanged", () => { expect(escapeRegExp("hello world")).toBe("hello world"); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/util/__tests__/page-number.spec.ts ================================================ import {describe, expect, it} from "vitest"; import {formatNumber} from "../numbering"; describe("formatNumber", () => { it("formats arabic numbers", () => { expect(formatNumber(5, "1")).toBe("5"); }); it("formats lower-alpha", () => { expect(formatNumber(1, "a")).toBe("a"); expect(formatNumber(26, "a")).toBe("z"); }); it("formats upper-alpha", () => { expect(formatNumber(1, "A")).toBe("A"); expect(formatNumber(26, "A")).toBe("Z"); }); it("formats lower-roman", () => { expect(formatNumber(1, "i")).toBe("i"); expect(formatNumber(4, "i")).toBe("iv"); expect(formatNumber(1999, "i")).toBe("mcmxcix"); }); it("formats upper-roman", () => { expect(formatNumber(9, "I")).toBe("IX"); }); it("returns the format string for unknown formats", () => { expect(formatNumber(3, "x")).toBe("x"); }); }); ================================================ FILE: quarkdown-html/src/main/typescript/util/browser.ts ================================================ /** * @returns whether the current browser is Safari (excludes Chrome-based and Android browsers). */ export function isSafari(): boolean { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); } ================================================ FILE: quarkdown-html/src/main/typescript/util/escape.ts ================================================ /** * Escapes HTML special characters to prevent XSS. * @param text - The text to escape * @returns The escaped HTML-safe string */ export function escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } /** * Escapes special regex characters in a string. * @param text - The string to escape * @returns The escaped string safe for use in a RegExp */ export function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } ================================================ FILE: quarkdown-html/src/main/typescript/util/hash.ts ================================================ /** * Generates a hash code from a given string. * @param str The input string. * @returns A hash code as a string. */ export function hashCode(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; } return hash.toString(); } ================================================ FILE: quarkdown-html/src/main/typescript/util/id.ts ================================================ /** * Extracts the target ID from an anchor element's href attribute. * @param link - The anchor element to extract the target ID from * @returns The decoded target ID (without the leading #), or undefined if invalid */ export function getAnchorTargetId(link: HTMLAnchorElement): string | undefined { const href = link.getAttribute('href'); if (!href || !href.startsWith('#')) { return undefined; } let decoded: string; try { decoded = decodeURIComponent(href); } catch { return undefined; } const id = decoded.slice(1); return id.length > 0 ? id : undefined; } ================================================ FILE: quarkdown-html/src/main/typescript/util/meta.ts ================================================ const ROOT_PATH_META = "quarkdown:root-path"; /** * Retrieves the content of a meta tag by its name. * @param name - The name attribute of the meta tag * @returns The content of the meta tag, or null if not found */ export function getMetaContent(name: string): string | null { const meta = document.querySelector(`meta[name="${name}"]`); return meta?.getAttribute("content") ?? null; } /** * Retrieves the path to the root of the Quarkdown output directory, * from the `quarkdown:root-path` meta tag. * @returns The root path, or "/" if not specified */ export function getRootPath(): string { return getMetaContent(ROOT_PATH_META) || "/"; } ================================================ FILE: quarkdown-html/src/main/typescript/util/numbering.ts ================================================ import {romanize} from "romans"; /** * Formats a number according to the specified numbering format. * This mirrors the behavior from the main compiler. * Supported formats: * - 1 (arabic) * - a (lower-alpha) * - A (upper-alpha) * - i (lower-roman) * - I (upper-roman) * For unknown formats the format string itself is returned. * @param number The number to format. * @param format The format to use for formatting the page number. * @returns The formatted page number as a string. */ export function formatNumber(number: number, format: string): string { switch (format) { case "1": return number.toString(); case "a": return String.fromCharCode('a'.charCodeAt(0) + number - 1); case "A": return String.fromCharCode('A'.charCodeAt(0) + number - 1); case "i": return romanize(number).toLowerCase(); case "I": return romanize(number); default: return format; } } ================================================ FILE: quarkdown-html/src/main/typescript/util/visibility.ts ================================================ /** * Check if an element is hidden (has the data-hidden attribute). * @param element The element to check. * @returns Whether the element is hidden. */ export function isHidden(element: Element): boolean { return element.hasAttribute("data-hidden"); } /** * Check if an element is blank (has no children or only hidden children). * @param element The element to check. * @returns Whether the element is blank. */ export function isBlank(element: HTMLElement): boolean { return element.childNodes.length === 0 || Array.from(element.children).every(child => isHidden(child)); } ================================================ FILE: quarkdown-html/src/test/e2e/__util/compile.ts ================================================ import {execSync} from "child_process"; import * as path from "path"; import {OUTPUT_DIR, PROJECT_ROOT} from "./paths"; const DEFAULT_CLI_PATH = path.join(PROJECT_ROOT, "build/install/quarkdown/bin/quarkdown"); export const CLI_PATH = process.env.QUARKDOWN_CLI_PATH || DEFAULT_CLI_PATH; /** * Compiles a Quarkdown source file to HTML. * Uses the pre-built CLI from installDist. * @param source - Path to the source .qd file * @param outName - Name for the output directory * @returns Path to the compiled output directory */ export function compile(source: string, outName: string): string { const sourcePath = path.isAbsolute(source) ? source : path.join(PROJECT_ROOT, source); execSync(`"${CLI_PATH}" compile "${sourcePath}" --out "${OUTPUT_DIR}" --out-name "${outName}"`, { cwd: PROJECT_ROOT, stdio: "pipe", }); return path.join(OUTPUT_DIR, outName); } ================================================ FILE: quarkdown-html/src/test/e2e/__util/css.ts ================================================ import {Locator, Page} from "@playwright/test"; /** * Gets the full visible text of an element including ::before and ::after pseudo-element content. * @param locator - Playwright locator for the element * @returns The combined text content (before + inner + after), trimmed */ export async function getFullText(locator: Locator): Promise<string> { return locator.evaluate((el: Element) => { const extractContent = (pseudo: string) => { const content = getComputedStyle(el, pseudo).content; return content === "none" ? "" : content.replace(/^"|"$/g, ""); }; return (extractContent("::before") + el.textContent + extractContent("::after")).trim(); }); } /** * Gets the raw CSS content value of the ::before pseudo-element. * @param locator - Playwright locator for the element * @returns The raw content value (e.g., '"1"' or 'none') */ export async function getBeforeContent(locator: Locator): Promise<string> { return locator.evaluate((el: Element) => getComputedStyle(el, "::before").content); } /** * Checks if the ::before pseudo-element is on the same line as the element's text. * Compares the Y position of the ::before (via element box) with the text content's Y. * @param locator - Playwright locator for the element * @returns True if the ::before and text share the same vertical position */ export async function isBeforeInline(locator: Locator): Promise<boolean> { return locator.evaluate((el: Element) => { const textNode = el.firstChild; if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return false; const range = document.createRange(); range.selectNodeContents(textNode); const textRect = range.getBoundingClientRect(); const elementRect = el.getBoundingClientRect(); // ::before is inline if element top matches text top (within 1px tolerance) return Math.abs(elementRect.top - textRect.top) <= 1; }); } /** * Computes a CSS value by applying it to a temporary element and reading the computed result. * @param context - Playwright locator or Page for context * @param property - CSS property to set (e.g., "height", "color") * @param value - CSS value to compute (e.g., "var(--qd-link-color)", "1.3em") * @param getter - Function to extract the computed value from the temp element */ async function computeCssValue<T>( context: Locator | Page, property: string, value: string, getter: (el: HTMLElement) => T ): Promise<T> { const locator = "goto" in context ? context.locator("body") : context; return locator.evaluate( (el, {prop, val, getterFn}) => { const temp = document.createElement("div"); temp.style.setProperty(prop, val); el.appendChild(temp); const result = new Function("el", `return ${getterFn}`)(temp); temp.remove(); return result; }, {prop: property, val: value, getterFn: getter.toString().replace(/^\(?\w+\)?\s*=>\s*/, "")} ); } /** * Gets the computed pixel value of a CSS size in the context of a specific element. * Useful for em values which are relative to the element's inherited font-size. * @param context - Playwright locator for the context element, or Page (uses body) * @param value - CSS value to compute (e.g., "var(--qd-block-margin)", "1.3em") * @returns The computed size in pixels */ export async function getComputedSizeProperty(context: Locator | Page, value: string): Promise<number> { return computeCssValue(context, "height", value, (el) => el.getBoundingClientRect().height); } /** * Gets the computed RGB color value of a CSS value. * @param context - Playwright locator or Page for context * @param value - CSS color value (e.g., "var(--qd-link-color)", "hsl(0, 100%, 50%)") * @returns The computed color in RGB format (e.g., "rgb(168, 0, 0)") */ export async function getComputedColor(context: Locator | Page, value: string): Promise<string> { return computeCssValue(context, "color", value, (el) => getComputedStyle(el).color); } /** * Gets the computed style of an element. * @param locator - Playwright locator for the element * @returns The computed CSSStyleDeclaration as a plain object */ export async function evaluateComputedStyle(locator: Locator): Promise<CSSStyleDeclaration> { return locator.evaluate((el) => globalThis.getComputedStyle(el)); } ================================================ FILE: quarkdown-html/src/test/e2e/__util/free-port.ts ================================================ import * as net from "net"; /** * Finds an available TCP port by binding to port 0 and reading the OS-assigned port. * Each call returns a unique port, safe for parallel test execution. */ export async function findFreePort(): Promise<number> { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, () => { const addr = server.address(); if (addr && typeof addr === "object") { const port = addr.port; server.close(() => resolve(port)); } else { server.close(() => reject(new Error("Could not determine port"))); } }); server.on("error", reject); }); } ================================================ FILE: quarkdown-html/src/test/e2e/__util/global-setup.ts ================================================ import {execSync, spawn} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {CLI_PATH} from "./compile"; import {OUTPUT_DIR, PROJECT_ROOT} from "./paths"; const SERVER_PORT = 8089; const SERVER_STATE_FILE = path.join(OUTPUT_DIR, ".server-state.json"); export default async function globalSetup() { // Build CLI if not using a pre-built one if (!process.env.QUARKDOWN_CLI_PATH) { console.log("Building Quarkdown CLI..."); execSync("./gradlew installDist --quiet", { cwd: PROJECT_ROOT, stdio: "inherit", }); } else { console.log(`Using pre-built CLI: ${CLI_PATH}`); } // Ensure output directory exists fs.mkdirSync(OUTPUT_DIR, {recursive: true}); // Spawn Quarkdown server using pre-built CLI const proc = spawn(CLI_PATH, ["start", "-f", OUTPUT_DIR, "-p", String(SERVER_PORT)], { detached: true, stdio: "ignore", cwd: PROJECT_ROOT, }); proc.unref(); // Store server info for tests and teardown const url = `http://localhost:${SERVER_PORT}`; fs.writeFileSync(SERVER_STATE_FILE, JSON.stringify({url, pid: proc.pid})); // Wait for server to be ready await waitForServer(url); console.log(`E2E server started at ${url} (pid: ${proc.pid})`); } async function waitForServer(url: string, timeout = 10000): Promise<void> { const start = Date.now(); while (Date.now() - start < timeout) { try { const res = await fetch(url); if (res.status === 404 || res.ok) return; // 404 is fine, means server is up } catch { // Server not ready yet } await new Promise((r) => setTimeout(r, 100)); } throw new Error(`Server not ready at ${url} after ${timeout}ms`); } export function getServerUrl(): string { const state = JSON.parse(fs.readFileSync(SERVER_STATE_FILE, "utf-8")); return state.url; } ================================================ FILE: quarkdown-html/src/test/e2e/__util/global-teardown.ts ================================================ import * as fs from "fs"; import * as path from "path"; import {OUTPUT_DIR} from "./paths"; const SERVER_STATE_FILE = path.join(OUTPUT_DIR, ".server-state.json"); export default async function globalTeardown() { try { const state = JSON.parse(fs.readFileSync(SERVER_STATE_FILE, "utf-8")); if (state.pid) { process.kill(state.pid); console.log(`E2E server stopped (pid: ${state.pid})`); } fs.unlinkSync(SERVER_STATE_FILE); } catch { // Server state file doesn't exist or process already stopped } } ================================================ FILE: quarkdown-html/src/test/e2e/__util/live-preview-runner.ts ================================================ import {ChildProcess, spawn} from "child_process"; import {expect, FrameLocator, Page} from "@playwright/test"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import {CLI_PATH} from "./compile"; import {findFreePort} from "./free-port"; import {DocumentType, OUTPUT_DIR} from "./paths"; /** * Context passed to a live preview test function. */ export interface LivePreviewContext { /** The Playwright page instance. */ page: Page; /** * A FrameLocator targeting the currently visible iframe. * Re-evaluates on each access, so it automatically follows iframe swaps. */ activeFrame: FrameLocator; /** * Writes content to a file in the temp working directory. * For main.qd, automatically prepends the doctype declaration if one was specified. * @param relativePath - Path relative to the temp working directory (e.g., "main.qd", "sub.qd") * @param content - The new file content */ editFile: (relativePath: string, content: string) => void; } export interface LivePreviewTestOptions { /** Document type to prepend to main.qd (e.g., "plain", "paged", "slides", "docs"). */ docType?: DocumentType; /** * Subpath to navigate to within the live preview (e.g., "sub/index.html"). * Defaults to "index.html". */ subpath?: string; } /** * Polls a URL until it responds, with a timeout. */ async function waitForServer(url: string, timeout: number): Promise<void> { const start = Date.now(); while (Date.now() - start < timeout) { try { const res = await fetch(url); if (res.ok || res.status === 404) return; } catch { // Server not ready yet. } await new Promise((r) => setTimeout(r, 250)); } throw new Error(`Live preview server not ready at ${url} after ${timeout}ms`); } /** * Waits for the visible iframe's content to be fully rendered: * the `.quarkdown` element must be visible and `isReady()` must return true. */ async function waitForIframeReady(page: Page, timeout: number): Promise<void> { const deadline = Date.now() + timeout; // Wait for a visible iframe to exist. const visibleIframe = page.locator("iframe.visible"); await expect(visibleIframe).toBeVisible({timeout}); // Use frameLocator for assertions inside the iframe. const frameLocator = page.frameLocator("iframe.visible"); // Wait for .quarkdown to be visible inside the iframe. const remaining = Math.max(deadline - Date.now(), 1000); await expect(frameLocator.locator(".quarkdown")).toBeVisible({timeout: remaining}); // Wait for isReady() to return true inside the iframe. const iframeElement = await visibleIframe.elementHandle({timeout: 5000}); if (!iframeElement) throw new Error("Could not get iframe element handle"); const contentFrame = await iframeElement.contentFrame(); if (!contentFrame) throw new Error("Could not get iframe content frame"); const readyRemaining = Math.max(deadline - Date.now(), 1000); await contentFrame.waitForFunction(() => (window as any).isReady(), null, {timeout: readyRemaining}); } /** * Runs a live preview e2e test. Handles the full lifecycle: * 1. Copies test source files to a temp directory * 2. Finds a free port and spawns the CLI with `-p -w` * 3. Waits for the server and navigates to the live preview * 4. Waits for iframe readiness * 5. Calls the test function * 6. Cleans up (kills process, removes temp dir) * * @param testDir - Directory containing the test's .qd source files * @param page - Playwright page instance * @param fn - Test function receiving a LivePreviewContext * @param options - Optional configuration (docType, subpath) */ export async function runLivePreviewTest( testDir: string, page: Page, fn: (ctx: LivePreviewContext) => Promise<void>, options?: LivePreviewTestOptions, ): Promise<void> { const {docType, subpath} = options ?? {}; // Create a temp working directory and copy all .qd files into it. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qd-live-preview-")); const qdFiles = fs.readdirSync(testDir).filter((f) => f.endsWith(".qd")); for (const file of qdFiles) { fs.copyFileSync(path.join(testDir, file), path.join(tmpDir, file)); } // Prepend doctype to main.qd if specified. if (docType) { const mainPath = path.join(tmpDir, "main.qd"); const content = fs.readFileSync(mainPath, "utf-8"); const docTypeValue = docType === "slides-print" ? "slides" : docType; fs.writeFileSync(mainPath, `.doctype {${docTypeValue}}\n\n${content}`); } const port = await findFreePort(); const outName = `live-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; let proc: ChildProcess | null = null; try { // Spawn the CLI with compile + preview + watch flags. proc = spawn( CLI_PATH, [ "compile", path.join(tmpDir, "main.qd"), "--out", OUTPUT_DIR, "--out-name", outName, "-p", "-w", "--server-port", String(port), "-b", "none", ], { detached: true, stdio: "pipe", }, ); // Log stderr for debugging. proc.stderr?.on("data", (data: Buffer) => { const msg = data.toString().trim(); if (msg) console.error(`[live-preview stderr] ${msg}`); }); // Wait for the server to be ready (generous timeout for first compilation). await waitForServer(`http://localhost:${port}`, 30000); // Navigate to the live preview page. // The compile command starts the server rooted at the output subdirectory, // so the /live/ path is relative to that root (no outName prefix needed). const liveSubpath = subpath ?? "index.html"; const liveUrl = `http://localhost:${port}/live/${liveSubpath}`; await page.goto(liveUrl); // Wait for the iframe content to be ready. await waitForIframeReady(page, 30000); // Build the context for the test function. const ctx: LivePreviewContext = { page, get activeFrame() { return page.frameLocator("iframe.visible"); }, editFile(relativePath: string, content: string) { let finalContent = content; if (relativePath === "main.qd" && docType) { const docTypeValue = docType === "slides-print" ? "slides" : docType; finalContent = `.doctype {${docTypeValue}}\n\n${content}`; } fs.writeFileSync(path.join(tmpDir, relativePath), finalContent); }, }; await fn(ctx); } finally { // Kill the CLI process and its children. if (proc && proc.pid) { try { process.kill(-proc.pid, "SIGTERM"); } catch { try { proc.kill("SIGTERM"); } catch { // Process already exited. } } } // Clean up the temp directory. try { fs.rmSync(tmpDir, {recursive: true, force: true}); } catch { // Best-effort cleanup. } } } ================================================ FILE: quarkdown-html/src/test/e2e/__util/paths.ts ================================================ import * as path from "path"; export const PROJECT_ROOT = path.resolve(__dirname, "../../../../.."); export const OUTPUT_DIR = path.join(PROJECT_ROOT, "build/e2e"); export const ENTRY_POINT = "main.qd"; export type DocumentType = "plain" | "slides" | "slides-print" | "paged" | "docs"; ================================================ FILE: quarkdown-html/src/test/e2e/__util/runner.ts ================================================ import {expect, Page} from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; import {compile} from "./compile"; import {getServerUrl} from "./global-setup"; import {DocumentType, ENTRY_POINT} from "./paths"; /** Generates a unique ID for parallel test isolation. */ function uniqueId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } /** * Creates a temporary source file with a document type prepended. * Uses a unique ID to avoid conflicts in parallel execution. * @param testDir - Directory containing the main.qd file * @param docType - Document type to prepend * @param id - Unique identifier for this test run * @returns Path to the temporary source file */ function createSourceWithDocType(testDir: string, docType: DocumentType, id: string): string { const docTypeValue = docType === "slides-print" ? "slides" : docType; const sourcePath = path.join(testDir, ENTRY_POINT); const content = fs.readFileSync(sourcePath, "utf-8"); const withDocType = `.doctype {${docTypeValue}}\n\n${content}`; const tempPath = path.join(testDir, `main-${docType}-${id}.qd`); fs.writeFileSync(tempPath, withDocType); return tempPath; } export interface RunTestOptions { docType?: DocumentType; subpath?: string; } /** * Runs a test by compiling a document and navigating to it via the persistent server. * Waits for the document to be fully rendered before running the test function. * @param testDir - Directory containing the test's main.qd file * @param page - Playwright page instance * @param fn - Test function to execute * @param options - Optional configuration (docType, subpath) */ export async function runTest( testDir: string, page: Page, fn: (page: Page) => Promise<void>, options?: RunTestOptions ): Promise<void> { const {docType, subpath} = options ?? {}; const e2eDir = path.resolve(__dirname, ".."); const baseOutName = path.relative(e2eDir, testDir).split(path.sep).join("-"); const id = uniqueId(); let sourcePath: string; let outName: string; if (docType) { sourcePath = createSourceWithDocType(testDir, docType, id); outName = `${baseOutName}-${docType}-${id}`; } else { sourcePath = path.join(testDir, ENTRY_POINT); outName = baseOutName; } try { compile(sourcePath, outName); const query = docType === "slides-print" ? "print-pdf" : ""; const subpathSegment = subpath ? `/${subpath}` : ""; const url = `${getServerUrl()}/${outName}${subpathSegment}/?${query}`; await page.goto(url); await waitForReady(page); await fn(page); } finally { if (docType) { fs.unlinkSync(sourcePath); } } } /** * Waits for the Quarkdown document to be fully rendered. */ async function waitForReady(page: Page): Promise<void> { await expect(page.locator(".quarkdown")).toBeVisible(); await page.waitForFunction(() => (window as any).isReady()); } ================================================ FILE: quarkdown-html/src/test/e2e/alerts/box/box.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); const BOX_TYPES = ["callout", "tip", "note", "warning", "error"] as const; test("renders box types with correct colors and icons", async (page) => { const boxes = page.locator(".box"); await expect(boxes).toHaveCount(5); for (const type of BOX_TYPES) { const box = page.locator(`.box.${type}`); await expect(box).toBeAttached(); const header = box.locator("> header"); const content = box.locator("> .box-content"); await expect(header).toBeAttached(); await expect(content).toBeAttached(); // Get expected colors for this box type const titleFgColor = await getComputedColor(page, `var(--qd-${type}-title-foreground-color)`); const contentFgColor = await getComputedColor(page, `var(--qd-${type}-content-foreground-color)`); const bgColor = await getComputedColor(page, `var(--qd-${type}-background-color)`); await expect(header).toHaveCSS("color", titleFgColor); await expect(box).toHaveCSS("background-color", bgColor); // Header background should be different from content background const headerBg = await header.evaluate((el) => getComputedStyle(el).backgroundColor); expect(headerBg).not.toBe(bgColor); // Content text color should match content foreground color await expect(content).toHaveCSS("color", contentFgColor); // Non-callout boxes have an icon in header > h4::before if (type !== "callout" && type !== "error") { const beforeContent = await header.locator("h4") .evaluate((el) => getComputedStyle(el, "::before").content); expect(beforeContent).not.toBe("none"); expect(beforeContent).not.toBe('""'); } } }); ================================================ FILE: quarkdown-html/src/test/e2e/alerts/box/main.qd ================================================ .theme layout:{minimal} .box {Title} type:{callout} Content .box {Title} type:{tip} Content .box {Title} type:{note} Content .box {Title} type:{warning} Content .box {Title} type:{error} Content ================================================ FILE: quarkdown-html/src/test/e2e/alerts/quote/main.qd ================================================ .doclang {en} .theme layout:{minimal} > Tip: Content > Note: Content > Warning: Content > Important: Content ================================================ FILE: quarkdown-html/src/test/e2e/alerts/quote/quote.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); const ALERT_TYPES = ["tip", "note", "warning", "important"] as const; testMatrix("renders blockquote alerts with correct colors", ["plain", "slides"], async (page) => { const blockquotes = page.locator("blockquote"); await expect(blockquotes).toHaveCount(4); for (let i = 0; i < ALERT_TYPES.length; i++) { const type = ALERT_TYPES[i]; const blockquote = blockquotes.nth(i); await expect(blockquote).toBeAttached(); const headerFgColor = await getComputedColor(page, `var(--qd-${type}-title-foreground-color)`); await expect(blockquote).toHaveCSS("border-color", headerFgColor); // p::before should have content "Type: " with same color const p = blockquote.locator("p").first(); const beforeContent = await p.evaluate((el) => getComputedStyle(el, "::before").content); const expectedContent = `"${type.charAt(0).toUpperCase() + type.slice(1)}: "`; expect(beforeContent).toBe(expectedContent); const beforeColor = await p.evaluate((el) => getComputedStyle(el, "::before").color); expect(beforeColor).toBe(headerFgColor); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/alignment/container/container.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "inherits alignment in containers", ["plain", "paged"], async (page) => { const paragraphs = page.locator("p"); const first = paragraphs.nth(0); const second = paragraphs.nth(1); const third = paragraphs.nth(2); // First paragraph: regular, not inside a container. await expect(first).toHaveCSS("text-align", "justify"); await expect(first).toHaveCSS("text-align-last", "start"); // Second paragraph: direct child of .center, // hence [style*="text-align"] > p unsets alignment, inheriting center from parent. await expect(second).toHaveCSS("text-align", "center"); await expect(second).toHaveCSS("text-align-last", "auto"); // Third paragraph: inside a blockquote inside .center (not a direct child), // so the unset rule does not apply and local alignment is used. await expect(third).toHaveCSS("text-align", "justify"); await expect(third).toHaveCSS("text-align-last", "start"); }, ); ================================================ FILE: quarkdown-html/src/test/e2e/alignment/container/main.qd ================================================ .loremipsum .center .loremipsum .center > .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/alignment/doctype/doctype.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies correct text alignment per doctype", ["plain", "paged", "slides", "slides-print", "docs"], async (page, docType) => { const heading = page.locator("h1").first(); const paragraph = page.locator("p").first(); const listItem = page.locator("li").first(); const inlineMath = page.locator("p > formula").first(); const blockMath = page.locator("formula[data-block]").first(); let alignmentGlobal = "start"; let alignmentLocal = "start"; let alignmentListItem = "start"; if (docType === "slides" || docType === "slides-print") { alignmentGlobal = "center"; alignmentLocal = "center"; } else if (docType === "plain" || docType === "paged") { alignmentLocal = "justify"; alignmentListItem = "justify"; } await expect(heading).toHaveCSS("text-align", alignmentGlobal); await expect(paragraph).toHaveCSS("text-align", alignmentLocal); await expect(paragraph).toHaveCSS("text-align-last", alignmentGlobal); await expect(listItem).toHaveCSS("text-align", alignmentListItem); await expect(listItem).toHaveCSS("text-align-last", "start"); await expect(inlineMath).toHaveCSS("text-align", "start"); await expect(inlineMath).toHaveCSS("text-align-last", "auto"); await expect(blockMath).toHaveCSS("text-align", "start"); await expect(blockMath).toHaveCSS("text-align-last", "auto"); } ); ================================================ FILE: quarkdown-html/src/test/e2e/alignment/doctype/main.qd ================================================ .theme layout:{minimal} # Title Content $ \frac{123}{12412323} $ - Content - Content $ \frac{123}{12412323} $ ================================================ FILE: quarkdown-html/src/test/e2e/captions/customized/customized.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertFigureCaption, assertTableCaption} from "../index"; const {test} = suite(__dirname); test("renders image figure caption on top", async (page) => { await assertFigureCaption(page, "img", "top"); }); test("renders code figure caption on top", async (page) => { await assertFigureCaption(page, "pre", "top"); }); test("renders table caption on top", async (page) => { await assertTableCaption(page, "top"); }); ================================================ FILE: quarkdown-html/src/test/e2e/captions/customized/main.qd ================================================ .captionposition {top} ![](../../__assets/rect.png "Caption") | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Caption" ```text "Caption" Code ``` ================================================ FILE: quarkdown-html/src/test/e2e/captions/default/default.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertFigureCaption, assertTableCaption} from "../index"; const {test} = suite(__dirname); test("renders image figure caption on bottom", async (page) => { await assertFigureCaption(page, "img", "bottom"); }); test("renders code figure caption on bottom", async (page) => { await assertFigureCaption(page, "pre", "bottom"); }); test("renders table caption on bottom", async (page) => { await assertTableCaption(page, "bottom"); }); ================================================ FILE: quarkdown-html/src/test/e2e/captions/default/main.qd ================================================ ![](../../__assets/rect.png "Caption") | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Caption" ```text "Caption" Code ``` ================================================ FILE: quarkdown-html/src/test/e2e/captions/index.ts ================================================ import {expect, Page} from "@playwright/test"; export type CaptionPosition = "top" | "bottom"; async function assertCaptionPosition( contentBox: {y: number; height: number}, captionBox: {y: number; height: number}, position: CaptionPosition ) { if (position === "bottom") { expect(captionBox.y).toBeGreaterThan(contentBox.y + contentBox.height - 1); } else { expect(captionBox.y + captionBox.height).toBeLessThanOrEqual(contentBox.y + 1); } } export async function assertFigureCaption( page: Page, contentSelector: string, position: CaptionPosition ) { const figure = page.locator(`figure:has(${contentSelector})`); await expect(figure).toBeAttached(); const content = figure.locator(contentSelector); const figcaption = figure.locator("figcaption"); await expect(content).toBeAttached(); await expect(figcaption).toBeAttached(); await expect(figcaption).toHaveText("Caption"); const contentBox = await content.boundingBox(); const captionBox = await figcaption.boundingBox(); expect(contentBox).not.toBeNull(); expect(captionBox).not.toBeNull(); await assertCaptionPosition(contentBox!, captionBox!, position); } export async function assertTableCaption(page: Page, position: CaptionPosition) { const table = page.locator("table"); await expect(table).toBeAttached(); const caption = table.locator("caption"); await expect(caption).toBeAttached(); await expect(caption).toHaveText("Caption"); const tbody = table.locator("tbody"); const tbodyBox = await tbody.boundingBox(); const captionBox = await caption.boundingBox(); expect(tbodyBox).not.toBeNull(); expect(captionBox).not.toBeNull(); await assertCaptionPosition(tbodyBox!, captionBox!, position); } ================================================ FILE: quarkdown-html/src/test/e2e/code/caption/caption.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("displays caption", async (page) => { await expect(page.locator("pre code.hljs")).toBeVisible(); await expect(page.locator("figcaption")).toHaveText("A wrapper class"); }); ================================================ FILE: quarkdown-html/src/test/e2e/code/caption/main.qd ================================================ .code lang:{java} caption:{A wrapper class} public final class Wrapper<T> { private final T value; public Wrapper(T value) { this.value = value; } public T getValue() { return this.value; } } ================================================ FILE: quarkdown-html/src/test/e2e/code/default/default.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("highlights code and shows line numbers", async (page) => { await expect(page.locator("pre code.hljs")).toBeVisible(); await expect(page.locator(".hljs-ln-numbers")).toHaveCount(11); }); ================================================ FILE: quarkdown-html/src/test/e2e/code/default/main.qd ================================================ ```java public final class Wrapper<T> { private final T value; public Wrapper(T value) { this.value = value; } public T getValue() { return this.value; } } ``` ================================================ FILE: quarkdown-html/src/test/e2e/code/focus/focus.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("focuses lines 4-6 with reduced opacity on others", async (page) => { await expect(page.locator("code.focus-lines")).toBeVisible(); // Lines 4, 5, 6 should have .focused class await expect(page.locator(".hljs-ln-line.focused")).toHaveCount(6); // 3 lines * 2 cells each // Non-focused lines should have reduced opacity const nonFocused = page.locator("code.focus-lines .hljs-ln-line:not(.focused)").first(); const opacity = await nonFocused.evaluate((el) => parseFloat(getComputedStyle(el).opacity)); expect(opacity).toBeLessThan(1); }); ================================================ FILE: quarkdown-html/src/test/e2e/code/focus/main.qd ================================================ .code lang:{java} focus:{4..6} public final class Wrapper<T> { private final T value; public Wrapper(T value) { this.value = value; } public T getValue() { return this.value; } } ================================================ FILE: quarkdown-html/src/test/e2e/code/no-line-numbers/main.qd ================================================ .code lang:{java} linenumbers:{no} public final class Wrapper<T> { private final T value; public Wrapper(T value) { this.value = value; } public T getValue() { return this.value; } } ================================================ FILE: quarkdown-html/src/test/e2e/code/no-line-numbers/no-line-numbers.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("highlights code without line numbers", async (page) => { await expect(page.locator("pre code.hljs")).toBeVisible(); await expect(page.locator(".hljs-ln-numbers")).toHaveCount(0); }); ================================================ FILE: quarkdown-html/src/test/e2e/code/numbered/main.qd ================================================ .numbering - code: 1 ```javascript console.log("Hello, World!"); ``` .code lang:{python} caption:{A print statement} print("Hello, World!") ================================================ FILE: quarkdown-html/src/test/e2e/code/numbered/numbered.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); async function getCaptionBeforeContent(page: any, figcaption: any): Promise<string> { return figcaption.evaluate((el: Element) => getComputedStyle(el, "::before").content); } test("displays numbered captions", async (page) => { const figures = page.locator("figure"); await expect(figures).toHaveCount(2); // First code block: numbering 1 displayed via ::before const caption1 = figures.nth(0).locator("figcaption"); expect(await getCaptionBeforeContent(page, caption1)).toContain("1"); // Second code block: numbering 2, with caption text const caption2 = figures.nth(1).locator("figcaption"); expect(await getCaptionBeforeContent(page, caption2)).toContain("2"); await expect(caption2).toContainText("A print statement"); }); ================================================ FILE: quarkdown-html/src/test/e2e/code/page-split/main.qd ================================================ .doctype {paged} .pageformat height:{6cm} ```text Line 1 Line 2 Line 3 Line 4 ``` ================================================ FILE: quarkdown-html/src/test/e2e/code/page-split/page-split.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("splits code block across pages", async (page) => { const pages = page.locator(".pagedjs_page"); await expect(pages).toHaveCount(2); // Each page has one code block const firstPageCode = pages.nth(0).locator("pre code.hljs"); const secondPageCode = pages.nth(1).locator("pre code.hljs"); await expect(firstPageCode).toBeAttached(); await expect(secondPageCode).toBeAttached(); }); test("continues line numbers across split", async (page) => { const pages = page.locator(".pagedjs_page"); // First page has lines 1, 2 const firstPageLineNumbers = pages.nth(0).locator(".hljs-ln-n"); await expect(firstPageLineNumbers).toHaveCount(2); await expect(firstPageLineNumbers.nth(0)).toHaveAttribute("data-line-number", "1"); await expect(firstPageLineNumbers.nth(1)).toHaveAttribute("data-line-number", "2"); // Second page has lines 3, 4 const secondPageLineNumbers = pages.nth(1).locator(".hljs-ln-n"); await expect(secondPageLineNumbers).toHaveCount(2); await expect(secondPageLineNumbers.nth(0)).toHaveAttribute("data-line-number", "3"); await expect(secondPageLineNumbers.nth(1)).toHaveAttribute("data-line-number", "4"); }); test("preserves indentation across split", async (page) => { const pages = page.locator(".pagedjs_page"); // Get all code lines from both pages const firstPageLines = pages.nth(0).locator("pre code.hljs .hljs-ln-code"); const secondPageLines = pages.nth(1).locator("pre code.hljs .hljs-ln-code"); // Line 1: 0 spaces, Line 2: 4 spaces const line1Text = await firstPageLines.nth(0).textContent(); const line2Text = await firstPageLines.nth(1).textContent(); expect(line1Text).toMatch(/^Line 1/); expect(line2Text).toMatch(/^ {4}Line 2/); // Line 3: 8 spaces, Line 4: 12 spaces const line3Text = await secondPageLines.nth(0).textContent(); const line4Text = await secondPageLines.nth(1).textContent(); expect(line3Text).toMatch(/^ {8}Line 3/); expect(line4Text).toMatch(/^ {12}Line 4/); }); ================================================ FILE: quarkdown-html/src/test/e2e/colors/colors.spec.ts ================================================ import {getComputedColor} from "../__util/css"; import {suite} from "../quarkdown"; const {test, expect} = suite(__dirname); test("applies theme colors correctly", async (page) => { const root = page.locator(":root"); const heading = page.locator("h1").first(); const text = page.locator("p").first(); const link = page.locator("a").first(); const colorScheme = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--qd-color-scheme").trim() ); await expect(root).toHaveCSS("color-scheme", colorScheme); expect(colorScheme).toBe("dark"); const headingColor = await getComputedColor(page, "var(--qd-heading-color)"); const mainColor = await getComputedColor(page, "var(--qd-main-color)"); const linkColor = await getComputedColor(page, "var(--qd-link-color)"); await expect(heading).toHaveCSS("color", headingColor); await expect(text).toHaveCSS("color", mainColor); await expect(link).toHaveCSS("color", linkColor); }); ================================================ FILE: quarkdown-html/src/test/e2e/colors/main.qd ================================================ .theme {galactic} # Heading Text [Link](https://example.com) ================================================ FILE: quarkdown-html/src/test/e2e/cross-reference/default/default.spec.ts ================================================ import {getFullText} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("displays cross-references with numbers only", async (page) => { const refs = page.locator(".cross-reference"); await expect(refs).toHaveCount(3); const headingRef = refs.nth(0); const equationRef = refs.nth(1); const tableRef = refs.nth(2); await expect(headingRef).toHaveAttribute("data-location", "1"); await expect(equationRef).toHaveAttribute("data-location", "(1)"); await expect(tableRef).toHaveAttribute("data-location", "1"); expect(await getFullText(headingRef)).toEqual("1"); expect(await getFullText(equationRef)).toEqual("(1)"); expect(await getFullText(tableRef)).toEqual("1"); }); ================================================ FILE: quarkdown-html/src/test/e2e/cross-reference/default/main.qd ================================================ .numbering - headings: 1 - tables: 1 - equations: (1) # Title {#title} $ y = mx + b $ {#line-eq} | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Table" {#tab} See .ref {title}, .ref {line-eq} and .ref {tab}. ================================================ FILE: quarkdown-html/src/test/e2e/cross-reference/localized/localized.spec.ts ================================================ import {getFullText} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("displays cross-references with localized labels", async (page) => { const refs = page.locator(".cross-reference"); await expect(refs).toHaveCount(3); const headingRef = refs.nth(0); const equationRef = refs.nth(1); const tableRef = refs.nth(2); await expect(headingRef).toHaveAttribute("data-localized-kind", "Section"); expect(await getFullText(headingRef)).toEqual("Section 1"); expect(await getFullText(equationRef)).toEqual("(1)"); expect(await getFullText(tableRef)).toEqual("Table 1"); }); ================================================ FILE: quarkdown-html/src/test/e2e/cross-reference/localized/main.qd ================================================ .doclang {English} .numbering - headings: 1 - tables: 1 - equations: (1) # Title {#title} $ y = mx + b $ {#line-eq} | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Table" {#tab} See .ref {title}, .ref {line-eq} and .ref {tab}. ================================================ FILE: quarkdown-html/src/test/e2e/css/properties/main.qd ================================================ .cssproperties - link-color: red [Link](https://example.com) ================================================ FILE: quarkdown-html/src/test/e2e/css/properties/properties.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies CSS property overrides", async (page) => { const link = page.locator("a"); await expect(link).toBeAttached(); await expect(link).toHaveCSS("color", "rgb(255, 0, 0)"); }); ================================================ FILE: quarkdown-html/src/test/e2e/css/raw/main.qd ================================================ .css a { color: red; } [Link](https://example.com) ================================================ FILE: quarkdown-html/src/test/e2e/css/raw/raw.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies raw CSS styles", async (page) => { const link = page.locator("a"); await expect(link).toBeAttached(); await expect(link).toHaveCSS("color", "rgb(255, 0, 0)"); }); ================================================ FILE: quarkdown-html/src/test/e2e/docs/content-width/content-width.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); interface LayoutWidths { headerAsideFirst: number; headerMain: number; headerAsideLast: number; contentAsideFirst: number; contentMain: number; contentAsideLast: number; } async function getLayoutWidths(page: any): Promise<LayoutWidths> { const [ headerAsideFirstBox, headerMainBox, headerAsideLastBox, contentAsideFirstBox, contentMainBox, contentAsideLastBox, ] = await Promise.all([ page.locator(".quarkdown-docs > header > aside:first-child").boundingBox(), page.locator(".quarkdown-docs > header > main").boundingBox(), page.locator(".quarkdown-docs > header > aside:last-child").boundingBox(), page.locator(".quarkdown-docs > .content-wrapper > aside:first-child").boundingBox(), page.locator(".quarkdown-docs > .content-wrapper > main").boundingBox(), page.locator(".quarkdown-docs > .content-wrapper > aside:last-child").boundingBox(), ]); return { headerAsideFirst: headerAsideFirstBox!.width, headerMain: headerMainBox!.width, headerAsideLast: headerAsideLastBox!.width, contentAsideFirst: contentAsideFirstBox!.width, contentMain: contentMainBox!.width, contentAsideLast: contentAsideLastBox!.width, }; } test("layout widths match between simple content and long code block", async (page) => { // Get widths from simple page const simpleWidths = await getLayoutWidths(page); // Navigate to long-code page await page.goto(page.url().replace("/simple/", "/long-code/")); await page.waitForFunction(() => (window as any).isReady()); // Get widths from long-code page const longCodeWidths = await getLayoutWidths(page); // Assert all widths match expect(longCodeWidths.headerAsideFirst).toBe(simpleWidths.headerAsideFirst); expect(longCodeWidths.headerMain).toBe(simpleWidths.headerMain); expect(longCodeWidths.headerAsideLast).toBe(simpleWidths.headerAsideLast); expect(longCodeWidths.contentAsideFirst).toBe(simpleWidths.contentAsideFirst); expect(longCodeWidths.contentMain).toBe(simpleWidths.contentMain); expect(longCodeWidths.contentAsideLast).toBe(simpleWidths.contentAsideLast); }, {subpath: "simple"}); ================================================ FILE: quarkdown-html/src/test/e2e/docs/content-width/long-code.qd ================================================ .include {main.qd} ``` aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ``` ================================================ FILE: quarkdown-html/src/test/e2e/docs/content-width/main.qd ================================================ .doctype {docs} .doclang {en} .pagemargin {lefttop} .navigation role:{pagelist} - [Simple](simple.qd) - [Long Code](long-code.qd) .pagemargin {righttop} .tableofcontents ================================================ FILE: quarkdown-html/src/test/e2e/docs/content-width/simple.qd ================================================ .include {main.qd} Abc ================================================ FILE: quarkdown-html/src/test/e2e/docs/layout/layout.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); /** Asserts two values are approximately equal within 2px tolerance (accounts for scrollbar and sub-pixel rendering). */ function expectApprox(actual: number, expected: number) { expect(Math.abs(actual - expected)).toBeLessThanOrEqual(2); } test("header and content-wrapper have full viewport width", async (page) => { const viewportSize = page.viewportSize()!; const header = page.locator(".quarkdown-docs > header"); const contentWrapper = page.locator(".quarkdown-docs > .content-wrapper"); const headerBox = await header.boundingBox(); const contentWrapperBox = await contentWrapper.boundingBox(); expect(headerBox).not.toBeNull(); expect(contentWrapperBox).not.toBeNull(); expect(headerBox!.width).toBe(viewportSize.width); expect(contentWrapperBox!.width).toBe(viewportSize.width); }); test("header starts at y=0 and ends where content-wrapper starts", async (page) => { const header = page.locator(".quarkdown-docs > header"); const contentWrapper = page.locator(".quarkdown-docs > .content-wrapper"); const headerBox = await header.boundingBox(); const contentWrapperBox = await contentWrapper.boundingBox(); expect(headerBox).not.toBeNull(); expect(contentWrapperBox).not.toBeNull(); expect(headerBox!.y).toBe(0); expect(headerBox!.y + headerBox!.height).toBeCloseTo(contentWrapperBox!.y, 0); }); test("content-wrapper extends to viewport height", async (page) => { const viewportSize = page.viewportSize()!; const contentWrapper = page.locator(".quarkdown-docs > .content-wrapper"); const contentWrapperBox = await contentWrapper.boundingBox(); expect(contentWrapperBox).not.toBeNull(); // Content wrapper should extend to at least the viewport height // (it may be taller if content overflows) expect(contentWrapperBox!.y + contentWrapperBox!.height).toBeGreaterThanOrEqual(viewportSize.height); }); test("header and content-wrapper columns are aligned", async (page) => { const headerAsideFirst = page.locator(".quarkdown-docs > header > aside:first-child"); const headerMain = page.locator(".quarkdown-docs > header > main"); const headerAsideLast = page.locator(".quarkdown-docs > header > aside:last-child"); const contentAsideFirst = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const contentMain = page.locator(".quarkdown-docs > .content-wrapper > main"); const contentAsideLast = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); const [ headerAsideFirstBox, headerMainBox, headerAsideLastBox, contentAsideFirstBox, contentMainBox, contentAsideLastBox, ] = await Promise.all([ headerAsideFirst.boundingBox(), headerMain.boundingBox(), headerAsideLast.boundingBox(), contentAsideFirst.boundingBox(), contentMain.boundingBox(), contentAsideLast.boundingBox(), ]); expect(headerAsideFirstBox).not.toBeNull(); expect(headerMainBox).not.toBeNull(); expect(headerAsideLastBox).not.toBeNull(); expect(contentAsideFirstBox).not.toBeNull(); expect(contentMainBox).not.toBeNull(); expect(contentAsideLastBox).not.toBeNull(); // First aside (left sidebar): same x and width expectApprox(headerAsideFirstBox!.x, contentAsideFirstBox!.x); expectApprox(headerAsideFirstBox!.width, contentAsideFirstBox!.width); // Main content area: same x and width expectApprox(headerMainBox!.x, contentMainBox!.x); expectApprox(headerMainBox!.width, contentMainBox!.width); // Last aside (right sidebar): same x and width expectApprox(headerAsideLastBox!.x, contentAsideLastBox!.x); expectApprox(headerAsideLastBox!.width, contentAsideLastBox!.width); }); test("search field aligns with main content first child", async (page) => { const searchField = page.locator(".quarkdown-docs > header > main .search-field"); const mainFirstChild = page.locator(".quarkdown-docs > .content-wrapper > main > :first-child"); const searchFieldBox = await searchField.boundingBox(); const mainFirstChildBox = await mainFirstChild.boundingBox(); expect(searchFieldBox).not.toBeNull(); expect(mainFirstChildBox).not.toBeNull(); expectApprox(searchFieldBox!.x, mainFirstChildBox!.x); }); ================================================ FILE: quarkdown-html/src/test/e2e/docs/layout/main.qd ================================================ .doctype {docs} .doclang {en} .pagemargin {lefttop} # Left Sidebar .repeat {20} .loremipsum .pagemargin {righttop} .tableofcontents # Alpha First section content. ## Alpha One Alpha One content. .repeat {10} .loremipsum ## Alpha Two Alpha Two content. # Beta Beta section content. .repeat {10} .loremipsum # Gamma Gamma section content. .repeat {10} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/docs/layout/scroll.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("asides have correct scroll CSS properties", async (page) => { const contentAsideFirst = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const contentAsideLast = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); // Asides have overflow-y: auto from the SCSS const [asideFirstOverflowY, asideLastOverflowY] = await Promise.all([ contentAsideFirst.evaluate((el) => getComputedStyle(el).overflowY), contentAsideLast.evaluate((el) => getComputedStyle(el).overflowY), ]); expect(asideFirstOverflowY).toBe("auto"); expect(asideLastOverflowY).toBe("auto"); // Asides are sticky positioned const [asideFirstPosition, asideLastPosition] = await Promise.all([ contentAsideFirst.evaluate((el) => getComputedStyle(el).position), contentAsideLast.evaluate((el) => getComputedStyle(el).position), ]); expect(asideFirstPosition).toBe("sticky"); expect(asideLastPosition).toBe("sticky"); }); test("scrolling left aside does not affect main or right aside", async (page) => { const contentAsideFirst = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const contentAsideLast = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); // Get initial scroll positions const [initialAsideFirstScroll, initialAsideLastScroll, initialWindowScroll] = await Promise.all([ contentAsideFirst.evaluate((el) => el.scrollTop), contentAsideLast.evaluate((el) => el.scrollTop), page.evaluate(() => window.scrollY), ]); // Scroll the left aside await contentAsideFirst.evaluate((el) => el.scrollBy(0, 100)); // Verify left aside scrolled const asideFirstScrollAfter = await contentAsideFirst.evaluate((el) => el.scrollTop); expect(asideFirstScrollAfter).toBeGreaterThan(initialAsideFirstScroll); // Verify main (window) and right aside did not scroll const [asideLastScrollAfter, windowScrollAfter] = await Promise.all([ contentAsideLast.evaluate((el) => el.scrollTop), page.evaluate(() => window.scrollY), ]); expect(asideLastScrollAfter).toBe(initialAsideLastScroll); expect(windowScrollAfter).toBe(initialWindowScroll); }); test("scrolling window does not affect aside internal scroll", async (page) => { await page.emulateMedia({reducedMotion: "reduce"}); const contentAsideFirst = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const contentAsideLast = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); // Get initial aside scroll positions const [initialAsideFirstScroll, initialAsideLastScroll] = await Promise.all([ contentAsideFirst.evaluate((el) => el.scrollTop), contentAsideLast.evaluate((el) => el.scrollTop), ]); // Scroll the window await page.evaluate(() => window.scrollBy(0, 200)); // Verify window scrolled const windowScrollAfter = await page.evaluate(() => window.scrollY); expect(windowScrollAfter).toBeGreaterThan(0); // Verify aside internal scroll positions are unchanged const [asideFirstScrollAfter, asideLastScrollAfter] = await Promise.all([ contentAsideFirst.evaluate((el) => el.scrollTop), contentAsideLast.evaluate((el) => el.scrollTop), ]); expect(asideFirstScrollAfter).toBe(initialAsideFirstScroll); expect(asideLastScrollAfter).toBe(initialAsideLastScroll); }); test("anchor scroll positions heading correctly", async (page) => { await page.emulateMedia({reducedMotion: "reduce"}); const header = page.locator(".quarkdown-docs > header"); const headerBox = await header.boundingBox(); expect(headerBox).not.toBeNull(); // Get the aside's padding-top value const asidePaddingTop = await page.locator(".quarkdown-docs > .content-wrapper > aside:first-child") .evaluate((el) => parseFloat(getComputedStyle(el).paddingTop)); const expectedHeadingY = headerBox!.height + asidePaddingTop; // Navigate to the #gamma anchor await page.goto(page.url() + "#gamma"); const gammaHeading = page.locator("h1#gamma"); await expect(gammaHeading).toBeVisible(); const gammaBox = await gammaHeading.boundingBox(); expect(gammaBox).not.toBeNull(); // The heading should be positioned at header height + aside padding-top expect(gammaBox!.y).toBeCloseTo(expectedHeadingY, 0); }); ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/customization/_nav.qd ================================================ - [Page 1](page-1.qd) - [Page 2](page-2.qd) - [Nested](nested/nested-page.qd) ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/customization/_setup.qd ================================================ .theme {darko} layout:{minimal} .pagelistposition {righttop} .tocposition {lefttop} ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/customization/main.qd ================================================ .docname {My docs} .include {docs} ## Main title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/customization/page-1.qd ================================================ .docname {Page 1} .include {docs} ## Page 1 title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/customization/structure.spec.ts ================================================ import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); test("page list is at righttop and toc is at lefttop", async (page) => { // Body should have quarkdown-docs class and be visible const body = page.locator("body.quarkdown-docs"); await expect(body).toBeVisible(); // Table of contents should be in the LEFT aside (customized from default) const leftAside = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const toc = leftAside.locator('nav[data-role="table-of-contents"]'); await expect(toc).toBeAttached(); // Page list should be in the RIGHT aside (customized from default) const rightAside = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); const pageList = rightAside.locator('nav[data-role="page-list"]'); await expect(pageList).toBeAttached(); }); ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/_nav.qd ================================================ - [Page 1](page-1.qd) - [Page 2](page-2.qd) - [Nested](nested/nested-page.qd) ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/_setup.qd ================================================ .theme {darko} ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/main.qd ================================================ .docname {My docs} .include {docs} ## Main title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/nested/nested-page.qd ================================================ .docname {Nested Page} .include {docs} ## Nested Page title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/page-1.qd ================================================ .docname {Page 1} .include {docs} ## Page 1 title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/page-2.qd ================================================ .docname {Page 2} .include {docs} ## Page 2 title ================================================ FILE: quarkdown-html/src/test/e2e/docs/lib/default/structure.spec.ts ================================================ import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); const pages = [ {subpath: undefined, title: "My docs", heading: "Main title"}, {subpath: "page-1", title: "Page 1", heading: "Page 1 title"}, {subpath: "page-2", title: "Page 2", heading: "Page 2 title"}, {subpath: "nested-page", title: "Nested Page", heading: "Nested Page title"}, ]; for (const {subpath, title, heading} of pages) { const pageName = subpath ?? "root"; test(`${pageName}: docs body is visible with correct margins`, async (page) => { // Body should have quarkdown-docs class and be visible const body = page.locator("body.quarkdown-docs"); await expect(body).toBeVisible(); // Dark color scheme from darko theme await expect(page.locator("html")).toHaveCSS("color-scheme", "dark"); // Page list should be in the left aside const leftAside = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const pageList = leftAside.locator('nav[data-role="page-list"]'); await expect(pageList).toBeAttached(); // Table of contents should be in the right aside const rightAside = page.locator(".quarkdown-docs > .content-wrapper > aside:last-child"); const toc = rightAside.locator('nav[data-role="table-of-contents"]'); await expect(toc).toBeAttached(); }, {subpath}); test(`${pageName}: has correct title and heading`, async (page) => { // Check document title await expect(page).toHaveTitle(title); // Check h1 matches docname and is the only h1 const h1 = page.locator(".quarkdown-docs > .content-wrapper > main h1"); await expect(h1).toHaveCount(1); await expect(h1).toHaveText(title); // Check h2 heading in main content const h2 = page.locator(".quarkdown-docs > .content-wrapper > main h2"); await expect(h2.first()).toHaveText(heading); }, {subpath}); } ================================================ FILE: quarkdown-html/src/test/e2e/docs/long-pagelist/autoscroll.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("page list auto-scrolls to show current page", async (page) => { const aside = page.locator(".quarkdown-docs > .content-wrapper > aside:first-child"); const currentPageLink = page.locator('nav[data-role="page-list"] [aria-current]'); await expect(currentPageLink).toBeVisible(); // Wait for smooth scroll animation to complete await page.waitForTimeout(500); // Verify the aside has scrolled (scrollTop > 0 since current page is at the end of the list) const scrollTop = await aside.evaluate((el) => el.scrollTop); expect(scrollTop).toBeGreaterThan(0); // Verify the current page link is visible within the aside's viewport const asideBox = await aside.boundingBox(); const currentLinkBox = await currentPageLink.boundingBox(); expect(asideBox).not.toBeNull(); expect(currentLinkBox).not.toBeNull(); // The link should be within the visible portion of the aside const linkTop = currentLinkBox!.y; const linkBottom = linkTop + currentLinkBox!.height; const asideTop = asideBox!.y; const asideBottom = asideTop + asideBox!.height; expect(linkTop).toBeGreaterThanOrEqual(asideTop); expect(linkBottom).toBeLessThanOrEqual(asideBottom); }, {subpath: "page"}); ================================================ FILE: quarkdown-html/src/test/e2e/docs/long-pagelist/main.qd ================================================ .doctype {docs} .pagemargin {lefttop} .navigation role:{pagelist} - A - B - C - D - E - F - G - H - I - J - K - L - M - N - O - P - Q - R - S - T - U - V - W - X - Y - Z - [Page](page.qd) ================================================ FILE: quarkdown-html/src/test/e2e/docs/long-pagelist/page.qd ================================================ .include {main.qd} ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/main.qd ================================================ .doctype {docs} .include {setup.qd} ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/page-1.qd ================================================ .include {setup.qd} Content 1 ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/page-2.qd ================================================ .include {setup.qd} # Title .repeat {4} .loremipsum ## Subtitle Content 2 ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/page-3.qd ================================================ .include {setup.qd} Content 3 ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/pagelist.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("current page link is bold, others are normal", async (page) => { const nav = page.locator('nav[data-role="page-list"]'); await expect(nav).toBeAttached(); const links = nav.locator("a"); await expect(links).toHaveCount(3); // Page 2 link (current) should be bold const page2Link = links.filter({hasText: "Page 2"}); await expect(page2Link).toHaveCSS("font-weight", "700"); // Other links should be normal weight const page1Link = links.filter({hasText: "Page 1"}); const page3Link = links.filter({hasText: "Page 3"}); await expect(page1Link).toHaveCSS("font-weight", "400"); await expect(page3Link).toHaveCSS("font-weight", "400"); }, {subpath: "page-2"}); ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/setup.qd ================================================ .doclang {en} .pagemargin {lefttop} .navigation role:{pagelist} - [Page 1](page-1.qd) - [Page 2](page-2.qd) - [Page 3](page-3.qd) .pagemargin {righttop} .tableofcontents ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/sibling-pages.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); const NAV_SELECTOR = 'nav[data-role="page-list"]'; const BUTTON_AREA_SELECTOR = "#sibling-pages-button-area"; const PREVIOUS_LINK_SELECTOR = "#previous-page-anchor"; const NEXT_LINK_SELECTOR = "#next-page-anchor"; async function assertButtons( page: any, expect: any, hasPrevious: boolean, hasNext: boolean ) { const buttonArea = page.locator(BUTTON_AREA_SELECTOR); const previousLink = page.locator(PREVIOUS_LINK_SELECTOR); const nextLink = page.locator(NEXT_LINK_SELECTOR); const main = page.locator(".content-wrapper > main"); // Navigation with page list should exist await expect(page.locator(NAV_SELECTOR)).toBeAttached(); // Button area should exist await expect(buttonArea).toBeAttached(); if (hasPrevious) { await expect(previousLink).toBeAttached(); } else { await expect(previousLink).not.toBeAttached(); } if (hasNext) { await expect(nextLink).toBeAttached(); } else { await expect(nextLink).not.toBeAttached(); } // Layout assertions const mainBox = await main.boundingBox(); expect(mainBox).not.toBeNull(); if (hasPrevious && hasNext) { // Two buttons: each takes 50% width const previousBox = await previousLink.boundingBox(); const nextBox = await nextLink.boundingBox(); expect(previousBox).not.toBeNull(); expect(nextBox).not.toBeNull(); const halfWidth = mainBox!.width / 2; expect(previousBox!.width).toBeCloseTo(halfWidth, -2); expect(nextBox!.width).toBeCloseTo(halfWidth, -2); expect(previousBox!.x).toBeLessThan(nextBox!.x); } else if (hasPrevious) { // Only previous: takes 100% width const previousBox = await previousLink.boundingBox(); expect(previousBox).not.toBeNull(); expect(previousBox!.width).toBeCloseTo(mainBox!.width, -1); } else if (hasNext) { // Only next: takes 100% width const nextBox = await nextLink.boundingBox(); expect(nextBox).not.toBeNull(); expect(nextBox!.width).toBeCloseTo(mainBox!.width, -1); } } test("first page has only next button", async (page) => { await assertButtons(page, expect, false, true); }, {subpath: "page-1"}); test("middle page has both buttons", async (page) => { await assertButtons(page, expect, true, true); }, {subpath: "page-2"}); test("last page has only previous button", async (page) => { await assertButtons(page, expect, true, false); }, {subpath: "page-3"}); ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/toc-active-tracking.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("highlights currently viewed heading in toc", async (page) => { // Shrink viewport so the content area is small enough for headings // to scroll in and out of the observer's active zone. await page.setViewportSize({width: 1280, height: 300}); const toc = page.locator('aside nav[data-role="table-of-contents"]'); await expect(toc).toBeAttached(); const items = toc.locator("li[data-target-id]"); const count = await items.count(); expect(count).toBeGreaterThan(1); const activeItems = toc.locator("li.active"); // First item should be highlighted initially await expect(activeItems).toHaveCount(1); await expect(items.first()).toHaveClass(/active/); // Scroll the last heading to the top of the viewport const lastTargetId = await items.last().getAttribute("data-target-id"); await page.evaluate((id) => document.getElementById(id!)?.scrollIntoView({block: "start"}), lastTargetId); // Last item should be highlighted await expect(activeItems).toHaveCount(1); await expect(items.last()).toHaveClass(/active/); }, {subpath: "page-2"}); test("de-emphasizes all toc items when no heading is active", async (page) => { const toc = page.locator('aside nav[data-role="table-of-contents"]'); await expect(toc).toBeAttached(); const links = toc.locator("li > a"); const count = await links.count(); expect(count).toBeGreaterThan(1); // Remove any active state set by the observer await page.evaluate(() => document.querySelectorAll('nav[data-role="table-of-contents"] li.active') .forEach((el) => el.classList.remove("active")), ); // With no .active item, all links should be de-emphasized to the main color const mainColor = await getComputedColor(page, "var(--qd-main-color)"); for (const link of await links.all()) { await expect(link).toHaveCSS("color", mainColor); } }, {subpath: "page-2"}); ================================================ FILE: quarkdown-html/src/test/e2e/docs/multi-page/toc.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("page with content has visible heading and non-bold links", async (page) => { const heading = page.locator("h3#table-of-contents"); await expect(heading).toBeAttached(); await expect(heading).toBeVisible(); const nav = page.locator('nav[data-role="table-of-contents"]'); await expect(nav).toBeAttached(); const links = nav.locator("a"); const count = await links.count(); expect(count).toBeGreaterThan(0); // No links should be bold for (const link of await links.all()) { await expect(link).toHaveCSS("font-weight", "400"); } }, {subpath: "page-2"}); test("page without content has hidden heading and empty list", async (page) => { const heading = page.locator("h3#table-of-contents"); await expect(heading).toBeAttached(); await expect(heading).toHaveCSS("visibility", "hidden"); const nav = page.locator('nav[data-role="table-of-contents"]'); const list = nav.locator("> ol"); await expect(list).toBeAttached(); await expect(list).toBeEmpty(); }, {subpath: "page-1"}); ================================================ FILE: quarkdown-html/src/test/e2e/doctype/default/default.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders correct body classes and background", ["plain", "paged", "slides", "docs"], async (page, docType) => { const body = page.locator("body"); // Body has correct classes await expect(body).toHaveClass(/quarkdown/); await expect(body).toHaveClass(new RegExp(`quarkdown-${docType}`)); const bgColor = await getComputedColor(page, "var(--qd-background-color)"); if (docType === "paged") { // Paged: body has different background, pages have --qd-background-color const bodyBg = await body.evaluate((el) => getComputedStyle(el).backgroundColor); expect(bodyBg).not.toBe(bgColor); const pagedPage = page.locator(".pagedjs_page"); await expect(pagedPage).toHaveCSS("background-color", bgColor); } else { // Non-paged: body has --qd-background-color await expect(body).toHaveCSS("background-color", bgColor); } } ); testMatrix( "renders plain layout correctly", ["plain"], async (page) => { const body = page.locator("body"); const main = body.locator("> main"); const leftAside = body.locator("> aside#margin-area-left"); const rightAside = body.locator("> aside#margin-area-right"); await expect(main).toBeAttached(); await expect(leftAside).toBeAttached(); await expect(rightAside).toBeAttached(); // Content width is limited const mainBox = await main.boundingBox(); const bodyBox = await body.boundingBox(); expect(mainBox).not.toBeNull(); expect(bodyBox).not.toBeNull(); expect(mainBox!.width).toBeLessThan(bodyBox!.width); // Margins are at left and right of content const leftBox = await leftAside.boundingBox(); const rightBox = await rightAside.boundingBox(); expect(leftBox).not.toBeNull(); expect(rightBox).not.toBeNull(); expect(leftBox!.x).toBeLessThan(mainBox!.x); expect(rightBox!.x).toBeGreaterThan(mainBox!.x + mainBox!.width - 1); } ); testMatrix( "renders docs layout correctly", ["docs"], async (page) => { const body = page.locator("body"); const header = body.locator("> header"); const contentWrapper = body.locator("> .content-wrapper"); const contentMain = contentWrapper.locator("> main"); const leftAside = contentWrapper.locator("> aside#margin-area-left"); const rightAside = contentWrapper.locator("> aside#margin-area-right"); await expect(header).toBeAttached(); await expect(contentWrapper).toBeAttached(); await expect(contentMain).toBeAttached(); await expect(leftAside).toBeAttached(); await expect(rightAside).toBeAttached(); // Search bar is present in header const searchInput = header.locator("#search-input"); await expect(searchInput).toBeAttached(); // Footnote area is not present (no footnotes) const footnoteArea = page.locator("#footnote-area"); await expect(footnoteArea).not.toBeAttached(); // Content width is limited const mainBox = await contentMain.boundingBox(); const wrapperBox = await contentWrapper.boundingBox(); expect(mainBox).not.toBeNull(); expect(wrapperBox).not.toBeNull(); expect(mainBox!.width).toBeLessThan(wrapperBox!.width); // Header > main is aligned with content > main const headerMain = header.locator("> main"); const headerMainBox = await headerMain.boundingBox(); expect(headerMainBox).not.toBeNull(); expect(headerMainBox!.x).toBeCloseTo(mainBox!.x, -1); // Header margins are aligned with content margins const headerLeftAside = header.locator("> aside").first(); const headerRightAside = header.locator("> aside").last(); const headerLeftBox = await headerLeftAside.boundingBox(); const headerRightBox = await headerRightAside.boundingBox(); const contentLeftBox = await leftAside.boundingBox(); const contentRightBox = await rightAside.boundingBox(); expect(headerLeftBox).not.toBeNull(); expect(headerRightBox).not.toBeNull(); expect(contentLeftBox).not.toBeNull(); expect(contentRightBox).not.toBeNull(); expect(headerLeftBox!.x).toBeCloseTo(contentLeftBox!.x, -1); expect(headerRightBox!.x).toBeCloseTo(contentRightBox!.x, -1); } ); testMatrix( "renders paged layout correctly", ["paged"], async (page) => { const pages = page.locator(".pagedjs_pages"); const pagedPage = page.locator(".pagedjs_page"); await expect(pages).toBeAttached(); await expect(pagedPage).toHaveCount(1); // Page number attributes start from 1 and match await expect(pagedPage).toHaveAttribute("data-page-number", "1"); await expect(pagedPage).toHaveAttribute("data-display-page-number", "1"); // Content is in correct location const contentDiv = pagedPage.locator(".pagedjs_area > .pagedjs_page_content > div"); await expect(contentDiv).toBeAttached(); // Content contains the heading const heading = contentDiv.locator("h1"); await expect(heading).toBeAttached(); await expect(heading).toHaveText("Page"); } ); testMatrix( "renders slides layout correctly", ["slides"], async (page) => { const reveal = page.locator(".reveal"); const slides = reveal.locator("> .slides"); const section = slides.locator("> section"); const backgrounds = reveal.locator("> .backgrounds"); const slideBackground = backgrounds.locator("> .slide-background"); await expect(reveal).toBeAttached(); await expect(slides).toBeAttached(); await expect(section).toHaveCount(1); await expect(backgrounds).toBeAttached(); await expect(slideBackground).toHaveCount(1); // Slide number attribute starts from 1 await expect(section).toHaveAttribute("data-display-page-number", "1"); // Section contains the content const heading = section.locator("h1"); await expect(heading).toBeAttached(); await expect(heading).toHaveText("Page"); // Background is empty (no page margin content in this test) const bgChildren = await slideBackground.locator("> * > *").count(); expect(bgChildren).toBe(0); } ); ================================================ FILE: quarkdown-html/src/test/e2e/doctype/default/main.qd ================================================ # Page Content ================================================ FILE: quarkdown-html/src/test/e2e/doctype/responsiveness/main.qd ================================================ # Page Content ================================================ FILE: quarkdown-html/src/test/e2e/doctype/responsiveness/responsiveness.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); const DESKTOP_WIDTH = 1400; const MOBILE_WIDTH = 600; const TABLET_WIDTH = 1000; testMatrix( "plain layout reflows at sm breakpoint", ["plain"], async (page) => { const body = page.locator("body"); const main = body.locator("> main"); const leftAside = body.locator("> aside#margin-area-left"); const rightAside = body.locator("> aside#margin-area-right"); // Desktop: side-by-side layout await page.setViewportSize({width: DESKTOP_WIDTH, height: 800}); await expect(body).toHaveCSS("flex-direction", "row"); await expect(leftAside).toBeVisible(); const mainBoxDesktop = await main.boundingBox(); const bodyBoxDesktop = await body.boundingBox(); expect(mainBoxDesktop).not.toBeNull(); expect(bodyBoxDesktop).not.toBeNull(); expect(mainBoxDesktop!.width).toBeLessThan(bodyBoxDesktop!.width); // Mobile: stacked layout await page.setViewportSize({width: MOBILE_WIDTH, height: 800}); await expect(body).toHaveCSS("flex-direction", "column"); // Left aside is hidden on mobile await expect(leftAside).toHaveCSS("display", "none"); // Right aside is visible and below main (for footnotes) await expect(rightAside).toHaveCSS("display", "block"); const mainBoxForPosition = await main.boundingBox(); const rightAsideBox = await rightAside.boundingBox(); expect(mainBoxForPosition).not.toBeNull(); expect(rightAsideBox).not.toBeNull(); expect(rightAsideBox!.y).toBeGreaterThan(mainBoxForPosition!.y + mainBoxForPosition!.height - 1); // Main takes full width const mainBoxMobile = await main.boundingBox(); const bodyBoxMobile = await body.boundingBox(); expect(mainBoxMobile).not.toBeNull(); expect(bodyBoxMobile).not.toBeNull(); // Account for padding expect(mainBoxMobile!.width).toBeGreaterThan(bodyBoxMobile!.width * 0.9); } ); testMatrix( "docs layout reflows at md breakpoint", ["docs"], async (page) => { const body = page.locator("body"); const header = body.locator("> header"); const contentWrapper = body.locator("> .content-wrapper"); const headerAsideLeft = header.locator("> aside").first(); const contentMain = contentWrapper.locator("> main"); const contentLeftAside = contentWrapper.locator("> aside#margin-area-left"); // Desktop: side-by-side layout await page.setViewportSize({width: DESKTOP_WIDTH, height: 800}); await expect(contentWrapper).toHaveCSS("flex-direction", "row"); await expect(headerAsideLeft).toBeVisible(); // Content asides are sticky on desktop await expect(contentLeftAside).toHaveCSS("position", "sticky"); const mainBoxDesktop = await contentMain.boundingBox(); const wrapperBoxDesktop = await contentWrapper.boundingBox(); expect(mainBoxDesktop).not.toBeNull(); expect(wrapperBoxDesktop).not.toBeNull(); expect(mainBoxDesktop!.width).toBeLessThan(wrapperBoxDesktop!.width); // Tablet (below md): stacked layout await page.setViewportSize({width: TABLET_WIDTH, height: 800}); await expect(contentWrapper).toHaveCSS("flex-direction", "column"); // Header asides are hidden on tablet await expect(headerAsideLeft).not.toBeVisible(); // Content asides are static on tablet await expect(contentLeftAside).toHaveCSS("position", "static"); // Content aside has border-top const borderTop = await contentLeftAside.evaluate((el) => getComputedStyle(el).borderTopWidth); expect(parseFloat(borderTop)).toBeGreaterThan(0); // Main takes more width const mainBoxTablet = await contentMain.boundingBox(); const wrapperBoxTablet = await contentWrapper.boundingBox(); expect(mainBoxTablet).not.toBeNull(); expect(wrapperBoxTablet).not.toBeNull(); expect(mainBoxTablet!.width).toBeGreaterThan(wrapperBoxTablet!.width * 0.9); } ); ================================================ FILE: quarkdown-html/src/test/e2e/filetree/filetree.spec.ts ================================================ import {getBeforeContent} from "../__util/css"; import {suite} from "../quarkdown"; const {test, expect} = suite(__dirname); test("list has no unexpected margin or padding", async (page) => { const rootList = page.locator(".file-tree > ul"); await expect(rootList).toHaveCount(1); // Root ul should have controlled margin and padding from _filetree.scss, // not polluted by global list styles. const rootStyle = await rootList.evaluate((el) => { const s = getComputedStyle(el); return { listStyle: s.listStyleType, marginLeft: parseFloat(s.marginLeft), paddingLeft: parseFloat(s.paddingLeft), }; }); expect(rootStyle.listStyle).toBe("none"); expect(rootStyle.marginLeft).toBeLessThanOrEqual(16); expect(rootStyle.paddingLeft).toBeLessThanOrEqual(16); // Nested ul should also have no list style, controlled padding, and a left border. const nestedUl = page.locator(".file-tree ul ul"); await expect(nestedUl).toHaveCount(1); const nestedStyle = await nestedUl.evaluate((el) => { const s = getComputedStyle(el); return { listStyle: s.listStyleType, paddingLeft: parseFloat(s.paddingLeft), borderLeftStyle: s.borderLeftStyle, borderLeftWidth: parseFloat(s.borderLeftWidth), }; }); expect(nestedStyle.listStyle).toBe("none"); expect(nestedStyle.paddingLeft).toBeLessThanOrEqual(20); expect(nestedStyle.borderLeftStyle).toBe("solid"); expect(nestedStyle.borderLeftWidth).toBeGreaterThan(0); }); test("icons are present on all entries", async (page) => { const files = page.locator(".file-tree li.file"); const directories = page.locator(".file-tree li.directory"); const ellipsis = page.locator(".file-tree li.ellipsis"); await expect(files).toHaveCount(3); // main.ts, utils.ts, README.md await expect(directories).toHaveCount(1); // src await expect(ellipsis).toHaveCount(1); // Each file entry has a ::before icon. for (const file of await files.all()) { const content = await getBeforeContent(file); expect(content).not.toBe("none"); expect(content).not.toBe('""'); } // Directory entry has a ::before icon. const dirContent = await getBeforeContent(directories.first()); expect(dirContent).not.toBe("none"); expect(dirContent).not.toBe('""'); // Ellipsis entry has a ::before icon. const ellipsisContent = await getBeforeContent(ellipsis.first()); expect(ellipsisContent).not.toBe("none"); expect(ellipsisContent).not.toBe('""'); }); test("highlighted entries have background and fit-content width", async (page) => { const highlighted = page.locator(".file-tree li[data-highlighted]"); await expect(highlighted).toHaveCount(2); // main.ts, README.md const highlightColor = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--qd-file-tree-highlight-color").trim() ); // The variable should be defined. expect(highlightColor).toBeTruthy(); for (const entry of await highlighted.all()) { // Background color should be applied (not transparent/empty). const bg = await entry.evaluate((el) => getComputedStyle(el).backgroundColor); expect(bg).not.toBe("rgba(0, 0, 0, 0)"); // Width should be fit-content, meaning the element is narrower than its parent. const {entryWidth, parentWidth} = await entry.evaluate((el) => ({ entryWidth: el.getBoundingClientRect().width, parentWidth: el.parentElement!.getBoundingClientRect().width, })); expect(entryWidth).toBeLessThan(parentWidth); } // Non-highlighted entries should not have a background. const nonHighlighted = page.locator(".file-tree li:not([data-highlighted])"); for (const entry of await nonHighlighted.all()) { const bg = await entry.evaluate((el) => getComputedStyle(el).backgroundColor); expect(bg).toBe("rgba(0, 0, 0, 0)"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/filetree/main.qd ================================================ .filetree - src - **main.ts** - utils.ts - **README.md** - ... ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-full/customization-full.spec.ts ================================================ import {evaluateComputedStyle} from "../../__util/css"; import {isHashedFont} from "../index"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies different custom fonts to main, headings, and code", async (page) => { // Paragraph uses custom main font (hashed) const paragraph = page.locator("p"); const pStyle = await evaluateComputedStyle(paragraph); expect(isHashedFont(pStyle.fontFamily)).toBe(true); // Heading uses custom heading font (hashed) const h2 = page.locator("h2"); const h2Style = await evaluateComputedStyle(h2); expect(isHashedFont(h2Style.fontFamily)).toBe(true); // Code block uses custom code font (hashed) const codeBlock = page.locator("pre code"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(isHashedFont(codeBlockStyle.fontFamily)).toBe(true); // All three fonts are different from each other expect(pStyle.fontFamily).not.toBe(h2Style.fontFamily); expect(pStyle.fontFamily).not.toBe(codeBlockStyle.fontFamily); expect(h2Style.fontFamily).not.toBe(codeBlockStyle.fontFamily); }); ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-full/main.qd ================================================ .theme layout:{minimal} .font {../../__assets/NotoSans-Regular.ttf} heading:{../../__assets/Karla-Bold.ttf} code:{../../__assets/RobotoMono-Regular.ttf} ## Heading Text ``` code block ``` ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-localized/customization-localized.spec.ts ================================================ import {evaluateComputedStyle} from "../../__util/css"; import {fontFamilyMatches, getCssVar, isHashedFont} from "../index"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies custom fonts with localized fallback", async (page) => { const mainLocalizedFont = await getCssVar(page, "--qd-main-localized-font"); const headingLocalizedFont = await getCssVar(page, "--qd-heading-localized-font"); const codeFont = await getCssVar(page, "--qd-code-font"); // Localized fonts should still be set to Noto Serif SC expect(mainLocalizedFont).toContain("Noto Serif SC"); expect(headingLocalizedFont).toContain("Noto Serif SC"); // Paragraph uses custom font (hashed) as primary const paragraph = page.locator("p"); const pStyle = await evaluateComputedStyle(paragraph); expect(isHashedFont(pStyle.fontFamily)).toBe(true); // Localized font is included as fallback expect(pStyle.fontFamily).toContain("Noto Serif SC"); // Heading uses custom font (hashed) as primary const h2 = page.locator("h2"); const h2Style = await evaluateComputedStyle(h2); expect(isHashedFont(h2Style.fontFamily)).toBe(true); // Localized font is included as fallback expect(h2Style.fontFamily).toContain("Noto Serif SC"); // Custom fonts are different from each other expect(pStyle.fontFamily).not.toBe(h2Style.fontFamily); // Code font is unchanged (not customized, not localized) const codeBlock = page.locator("pre code"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(fontFamilyMatches(codeBlockStyle.fontFamily, codeFont)).toBe(true); expect(isHashedFont(codeBlockStyle.fontFamily)).toBe(false); }); ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-localized/main.qd ================================================ .doclang {zh} .theme layout:{minimal} .font {../../__assets/NotoSans-Regular.ttf} heading:{../../__assets/Karla-Bold.ttf} ## 你好 你好 ``` code block ``` ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-minimal-no-headings/customization-minimal-no-headings.spec.ts ================================================ import {testCustomFontApplication} from "../index"; import {suite} from "../../quarkdown"; const {test} = suite(__dirname); test("applies custom font to main, preserves headings and code font", async (page) => { await testCustomFontApplication(page, false); }); ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-minimal-no-headings/main.qd ================================================ .theme layout:{minimal} .font {../../__assets/NotoSans-Regular.ttf} ## Heading Text ``` code block ``` ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-minimal-on-headings/customization-minimal-on-headings.spec.ts ================================================ import {testCustomFontApplication} from "../index"; import {suite} from "../../quarkdown"; const {test} = suite(__dirname); test("applies custom font to main and headings, preserves code font", async (page) => { await testCustomFontApplication(page, true); }); ================================================ FILE: quarkdown-html/src/test/e2e/font/customization-minimal-on-headings/main.qd ================================================ .theme layout:{latex} .font {../../__assets/NotoSans-Regular.ttf} ## Heading Text ``` code block ``` ================================================ FILE: quarkdown-html/src/test/e2e/font/default/default.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../../__util/css"; import {fontFamilyMatches, getCssVar} from "../index"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies correct default fonts", ["plain", "paged", "slides"], async (page) => { const mainFont = await getCssVar(page, "--qd-main-font"); const headingFont = await getCssVar(page, "--qd-heading-font"); const boxHeadingFont = await getCssVar(page, "--qd-box-heading-font"); const codeFont = await getCssVar(page, "--qd-code-font"); // Paragraph uses main font const paragraph = page.locator("p").first(); const pStyle = await evaluateComputedStyle(paragraph); expect(fontFamilyMatches(pStyle.fontFamily, mainFont)).toBe(true); // Table uses main font const tableCell = page.locator("td").first(); const tdStyle = await evaluateComputedStyle(tableCell); expect(fontFamilyMatches(tdStyle.fontFamily, mainFont)).toBe(true); // Box content uses main font const boxContent = page.locator(".box p").first(); const boxContentStyle = await evaluateComputedStyle(boxContent); expect(fontFamilyMatches(boxContentStyle.fontFamily, mainFont)).toBe(true); // Heading (h2) uses heading font const h2 = page.locator("h2"); const h2Style = await evaluateComputedStyle(h2); expect(fontFamilyMatches(h2Style.fontFamily, headingFont)).toBe(true); // Box header (h4) uses box heading font const boxHeader = page.locator(".box h4"); const boxHeaderStyle = await evaluateComputedStyle(boxHeader); expect(fontFamilyMatches(boxHeaderStyle.fontFamily, boxHeadingFont)).toBe(true); // Code span uses code font const codeSpan = page.locator("code").first(); const codeSpanStyle = await evaluateComputedStyle(codeSpan); expect(fontFamilyMatches(codeSpanStyle.fontFamily, codeFont)).toBe(true); // Code block uses code font const codeBlock = page.locator("pre code"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(fontFamilyMatches(codeBlockStyle.fontFamily, codeFont)).toBe(true); } ); testMatrix( "applies correct default font sizes", ["plain", "paged", "slides", "docs"], async (page, docType) => { const mainFontSize = await getComputedSizeProperty(page, "var(--qd-main-font-size)"); const codeSpanFontSize = await getComputedSizeProperty(page, "var(--qd-code-span-font-size)"); const codeBlockFontSize = await getComputedSizeProperty(page, "var(--qd-code-block-font-size)"); // Paragraph uses main font size const paragraph = page.locator("p").first(); const pStyle = await evaluateComputedStyle(paragraph); expect(parseFloat(pStyle.fontSize)).toBeCloseTo(mainFontSize, 1); // Code span uses code span font size const codeSpan = page.locator("p code").first(); const codeSpanStyle = await evaluateComputedStyle(codeSpan); expect(parseFloat(codeSpanStyle.fontSize)).toBeCloseTo(codeSpanFontSize, 1); // Code block uses code block font size, or slides code block font size in slides const codeBlockParent = page.locator("pre"); const codeBlock = codeBlockParent.locator("code"); const slidesCodeBlockFontSize = await getComputedSizeProperty(codeBlockParent, "var(--qd-slides-code-block-font-size)"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(parseFloat(codeBlockStyle.fontSize)).toBeCloseTo( docType === "slides" ? slidesCodeBlockFontSize : codeBlockFontSize, docType === "slides" ? 0 : 1, ); } ); ================================================ FILE: quarkdown-html/src/test/e2e/font/default/main.qd ================================================ .theme layout:{latex} ## Heading Text | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | `code span` ``` code block ``` .box {Box} Text ================================================ FILE: quarkdown-html/src/test/e2e/font/index.ts ================================================ import type {Page} from "@playwright/test"; import {expect} from "@playwright/test"; import {evaluateComputedStyle} from "../__util/css"; /** * Gets the computed value of a CSS custom property. */ export async function getCssVar(page: Page, varName: string): Promise<string> { return page.evaluate((name) => { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }, varName); } /** * Checks if a font family is a hashed custom font (starts with quoted numeric hash). */ export function isHashedFont(fontFamily: string): boolean { return /^"-?\d+/.test(fontFamily); } /** * Checks if the computed font family matches the expected font family, * ignoring quotes and case. */ export function fontFamilyMatches(computedFontFamily: string, expectedFontFamily: string): boolean { const cleanedComputed = computedFontFamily.replace(/['"]/g, "").toLowerCase(); const cleanedExpected = expectedFontFamily.replace(/['"]/g, "").toLowerCase(); return cleanedComputed.startsWith(cleanedExpected); } /** * Tests custom font application with configurable heading behavior. * @param page - Playwright page * @param expectCustomHeadings - Whether headings should use the custom font */ export async function testCustomFontApplication(page: Page, expectCustomHeadings: boolean): Promise<void> { const codeFont = await getCssVar(page, "--qd-code-font"); // Paragraph uses custom font (hashed) const paragraph = page.locator("p"); const pStyle = await evaluateComputedStyle(paragraph); expect(isHashedFont(pStyle.fontFamily)).toBe(true); // Heading font depends on theme configuration const h2 = page.locator("h2"); const h2Style = await evaluateComputedStyle(h2); if (expectCustomHeadings) { expect(fontFamilyMatches(h2Style.fontFamily, pStyle.fontFamily)).toBe(true); } else { const headingFont = await getCssVar(page, "--qd-heading-font"); expect(fontFamilyMatches(h2Style.fontFamily, headingFont)).toBe(true); } // Code block font is unchanged (still uses default code font) const codeBlock = page.locator("pre code"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(fontFamilyMatches(codeBlockStyle.fontFamily, codeFont)).toBe(true); expect(codeBlockStyle.fontFamily).not.toBe(pStyle.fontFamily); } ================================================ FILE: quarkdown-html/src/test/e2e/font/localized-default/localized-default.spec.ts ================================================ import {evaluateComputedStyle} from "../../__util/css"; import {fontFamilyMatches, getCssVar} from "../index"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); const FALLBACK_HEADING_FONT = "Inter"; const FALLBACK_MAIN_FONT = "Lato"; test("sets localized font variables for zh locale", async (page) => { const mainLocalizedFont = await getCssVar(page, "--qd-main-localized-font"); const headingLocalizedFont = await getCssVar(page, "--qd-heading-localized-font"); // Localized fonts should be set to Noto Serif SC expect(mainLocalizedFont).toContain("Noto Serif SC"); expect(headingLocalizedFont).toContain("Noto Serif SC"); }); test("applies localized fonts to Chinese text, base fonts to non-Chinese", async (page) => { const mainLocalizedFont = await getCssVar(page, "--qd-main-localized-font"); const headingLocalizedFont = await getCssVar(page, "--qd-heading-localized-font"); const codeFont = await getCssVar(page, "--qd-code-font"); const headings = page.locator("h2"); const paragraphs = page.locator("p"); // Headings use localized font with fallback to base heading font const headingStyle = await evaluateComputedStyle(headings.nth(0)); expect(fontFamilyMatches( headingStyle.fontFamily, headingLocalizedFont + ", " + headingLocalizedFont + ", " + FALLBACK_HEADING_FONT ) ).toBe(true); // Main text uses localized font with fallback to base main font const pStyle = await evaluateComputedStyle(paragraphs.nth(0)); expect(fontFamilyMatches( pStyle.fontFamily, mainLocalizedFont + ", " + mainLocalizedFont + ", " + FALLBACK_MAIN_FONT )).toBe(true); // Code font is unchanged (not localized) const codeBlock = page.locator("pre code"); const codeBlockStyle = await evaluateComputedStyle(codeBlock); expect(fontFamilyMatches(codeBlockStyle.fontFamily, codeFont)).toBe(true); }); ================================================ FILE: quarkdown-html/src/test/e2e/font/localized-default/main.qd ================================================ .doclang {zh} .theme layout:{minimal} ## 你好 你好 ``` code block ``` ================================================ FILE: quarkdown-html/src/test/e2e/footnote/multiple/main.qd ================================================ A[^: First], B[^: Second], and C[^: Third]. .loremipsum[^: Fourth] ================================================ FILE: quarkdown-html/src/test/e2e/footnote/multiple/multiple.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders multiple footnotes correctly", ["plain", "paged", "slides", "docs"], async (page, docType) => { const definitions = page.locator(".footnote-definition"); await expect(definitions).toHaveCount(4); switch (docType) { case "plain": { const first = definitions.nth(0); const second = definitions.nth(1); const third = definitions.nth(2); const fourth = definitions.nth(3); const firstBox = await first.boundingBox(); const secondBox = await second.boundingBox(); const thirdBox = await third.boundingBox(); const fourthBox = await fourth.boundingBox(); expect(firstBox).not.toBeNull(); expect(secondBox).not.toBeNull(); expect(thirdBox).not.toBeNull(); expect(fourthBox).not.toBeNull(); // First, Second, Third should be stacked vertically const firstBottom = firstBox!.y + firstBox!.height; const secondBottom = secondBox!.y + secondBox!.height; const thirdBottom = thirdBox!.y + thirdBox!.height; expect(secondBox!.y).toBeCloseTo(firstBottom, 0); expect(thirdBox!.y).toBeCloseTo(secondBottom, 0); // Distance between consecutive footnotes should be equal const dist12 = secondBox!.y - firstBottom; const dist23 = thirdBox!.y - secondBottom; expect(dist12).toBeCloseTo(dist23, 0); // Distance to fourth should be greater (different paragraph) const dist34 = fourthBox!.y - thirdBottom; expect(dist34).toBeGreaterThan(dist12); break; } case "paged": { const footnoteArea = page.locator(".pagedjs_area .pagedjs_footnote_area"); await expect(footnoteArea.locator(".footnote-definition")).toHaveCount(4); break; } case "slides": { const footnoteArea = page.locator(".footnote-area"); await expect(footnoteArea).toBeAttached(); await expect(footnoteArea.locator(".footnote-definition")).toHaveCount(4); break; } case "docs": { const footnoteArea = page.locator("#footnote-area"); await expect(footnoteArea.locator(".footnote-definition")).toHaveCount(4); break; } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/footnote/single/main.qd ================================================ .loremipsum[^: Footnote] ================================================ FILE: quarkdown-html/src/test/e2e/footnote/single/single.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders footnote in correct area", ["plain", "paged", "slides", "slides-print", "docs"], async (page, docType) => { const reference = page.locator(".footnote-reference"); await expect(reference).toBeAttached(); const definition = page.locator(".footnote-definition"); await expect(definition).toBeAttached(); switch (docType) { case "plain": { // Plain: footnotes are in the right margin area const marginArea = page.locator("#margin-area-right"); await expect(marginArea.locator(".footnote-definition")).toBeAttached(); // Footnote should be roughly aligned with reference const refBox = await reference.boundingBox(); const defBox = await definition.boundingBox(); expect(refBox).not.toBeNull(); expect(defBox).not.toBeNull(); expect(Math.abs(defBox!.y - refBox!.y)).toBeLessThanOrEqual(30); break; } case "paged": { // Paged: footnotes are in the paged.js footnote area const footnoteArea = page.locator(".pagedjs_area .pagedjs_footnote_area"); await expect(footnoteArea.locator(".footnote-definition")).toBeAttached(); // Footnote area should be in the bottom fourth of the page const pageBox = await page.locator(".pagedjs_page").boundingBox(); const areaBox = await footnoteArea.boundingBox(); expect(pageBox).not.toBeNull(); expect(areaBox).not.toBeNull(); const bottomFourthStart = pageBox!.y + pageBox!.height * 0.75; expect(areaBox!.y).toBeGreaterThanOrEqual(bottomFourthStart); expect(areaBox!.height).toBeGreaterThan(0); // Footnote area bottom should be close to the top of the bottom margin const marginBottom = page.locator(".pagedjs_margin-bottom"); const marginBox = await marginBottom.boundingBox(); expect(marginBox).not.toBeNull(); const areaBottom = areaBox!.y + areaBox!.height; expect(areaBottom).toBeCloseTo(marginBox!.y, 1); break; } case "slides": case "slides-print": { // Slides: footnotes are in a footnote-area at the bottom of the slide const footnoteArea = page.locator(".footnote-area"); await expect(footnoteArea).toBeAttached(); await expect(footnoteArea.locator(".footnote-definition")).toBeAttached(); // Footnote area should be in the bottom half of the slide const slideBox = await page.locator(".reveal .slides section").first().boundingBox(); const areaBox = await footnoteArea.boundingBox(); expect(slideBox).not.toBeNull(); expect(areaBox).not.toBeNull(); const bottomHalfStart = slideBox!.y + slideBox!.height * 0.5; expect(areaBox!.y).toBeGreaterThanOrEqual(bottomHalfStart); break; } case "docs": { // Docs: footnotes are in the footnote-area at the bottom of the page const footnoteArea = page.locator("#footnote-area"); await expect(footnoteArea).toBeAttached(); await expect(footnoteArea.locator(".footnote-definition")).toBeAttached(); break; } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/hr/hr.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../__util/css"; import {suite} from "../quarkdown"; const {test, expect} = suite(__dirname); test("renders horizontal and vertical rules", async (page) => { const blockMargin = await getComputedSizeProperty(page, "var(--qd-block-margin)"); // Regular hr is horizontal (width > height) const regularHr = page.locator("hr").first(); await expect(regularHr).toBeAttached(); const regularBox = await regularHr.boundingBox(); const regularStyle = await evaluateComputedStyle(regularHr); expect(regularBox).not.toBeNull(); expect(regularBox!.width).toBeGreaterThan(regularBox!.height); expect(parseFloat(regularStyle.marginTop)).toBeCloseTo(blockMargin, 1); expect(regularStyle.marginBottom).toBe(regularStyle.marginTop); expect(regularStyle.marginLeft).not.toBe(regularStyle.marginTop); expect(regularStyle.marginRight).not.toBe(regularStyle.marginTop); // Hr in row is vertical (height > width, min 10px height) const rowHr = page.locator(".stack-row > hr"); await expect(rowHr).toBeAttached(); const rowBox = await rowHr.boundingBox(); const rowStyle = await evaluateComputedStyle(rowHr); expect(rowBox).not.toBeNull(); expect(rowBox!.height).toBeGreaterThan(rowBox!.width); expect(rowBox!.height).toBeGreaterThanOrEqual(10); expect(parseFloat(rowStyle.marginLeft)).toBeCloseTo(blockMargin, 1); expect(rowStyle.marginRight).toBe(rowStyle.marginLeft); expect(rowStyle.marginTop).not.toBe(rowStyle.marginLeft); expect(rowStyle.marginBottom).not.toBe(rowStyle.marginLeft); }); ================================================ FILE: quarkdown-html/src/test/e2e/hr/main.qd ================================================ A --- B --- .row C --- D ================================================ FILE: quarkdown-html/src/test/e2e/icon/icon.spec.ts ================================================ import {suite} from "../quarkdown"; const {test, expect} = suite(__dirname); test("renders icons correctly", async (page) => { // All icons have the bi class and specific icon class await expect(page.locator(".bi-heart")).toHaveCount(2); await expect(page.locator(".bi-star")).toHaveCount(1); await expect(page.locator(".bi-github")).toHaveCount(1); // Regular icons have the same size const listIcons = page.locator("li .icon-image"); await expect(listIcons).toHaveCount(3); const listIconSizes = await listIcons.evaluateAll((icons) => icons.map((icon) => parseFloat(getComputedStyle(icon).fontSize)) ); expect(new Set(listIconSizes).size).toBe(1); // Heading icon is larger than other icons const headingIcon = page.locator("h2 .icon-image"); const headingIconSize = await headingIcon.evaluate((icon) => parseFloat(getComputedStyle(icon).fontSize) ); expect(headingIconSize).toBeGreaterThan(listIconSizes[0]); }); ================================================ FILE: quarkdown-html/src/test/e2e/icon/main.qd ================================================ - .icon {heart} - .icon {star} - .icon {github} ## .icon {heart} ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_customization-not-affecting-list/bulletin.yml ================================================ - issue: 137 - pr: 139 - next-release: 1.6.3 - cause: `--qd-line-height` and other properties set by `paragraphstyle` ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_customization-not-affecting-list/customization-not-affecting-list.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("paragraph style applies to list items", async (page) => { const listItems = page.locator("li"); const paragraphs = page.locator("p"); await expect(listItems).toHaveCount(3); await expect(paragraphs).toHaveCount(2); // Get expected line-height from first list item (computed as px) const firstItemLineHeight = await listItems.first().evaluate((el) => getComputedStyle(el).lineHeight ); // List items have correct styling for (const item of await listItems.all()) { await expect(item).toHaveCSS("line-height", firstItemLineHeight); await expect(item).toHaveCSS("letter-spacing", /-0\.\d+px/); await expect(item).toHaveCSS("margin-top", "0px"); } // Paragraphs have same styling as list items for (const p of await paragraphs.all()) { await expect(p).toHaveCSS("line-height", firstItemLineHeight); await expect(p).toHaveCSS("letter-spacing", /-0\.\d+px/); await expect(p).toHaveCSS("margin-top", "0px"); } }); test("elements are contiguous with no gaps", async (page) => { const list = page.locator("ul"); const paragraphs = page.locator("p"); const listBox = await list.boundingBox(); const firstParagraphBox = await paragraphs.nth(0).boundingBox(); const secondParagraphBox = await paragraphs.nth(1).boundingBox(); expect(listBox).not.toBeNull(); expect(firstParagraphBox).not.toBeNull(); expect(secondParagraphBox).not.toBeNull(); // End of list should be start of first paragraph const listEndY = listBox!.y + listBox!.height; expect(listEndY).toBeCloseTo(firstParagraphBox!.y, 0); // End of first paragraph should be start of second paragraph const firstParagraphEndY = firstParagraphBox!.y + firstParagraphBox!.height; expect(firstParagraphEndY).toBeCloseTo(secondParagraphBox!.y, 0); }); ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_customization-not-affecting-list/main.qd ================================================ .paragraphstyle spacing:{0} lineheight:{0.8} letterspacing:{-0.05} - **def1:** .loremipsum - **def2:** .loremipsum - **def3:** .loremipsum .loremipsum .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_overjustified-list-in-page-break/bulletin.yml ================================================ - issue: 123 - pr: 125 - next-release: 1.6.2 - cause: Justification should be local rather than global ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_overjustified-list-in-page-break/main.qd ================================================ .doctype {paged} .pageformat height:{10cm} - A B C D E - A B C D E - A B C D E - A B C D E - A B C D E - A B C D E - A B C D E - A B C D E - A B C D E ================================================ FILE: quarkdown-html/src/test/e2e/list/issue_overjustified-list-in-page-break/overjustified-list-in-page-break.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); const TOTAL_ITEMS = 9; test("list splits correctly across pages", async (page) => { const pages = page.locator(".pagedjs_page"); await expect(pages).toHaveCount(2); const firstPageItems = pages.nth(0).locator("li"); const secondPageItems = pages.nth(1).locator("li"); const firstPageCount = await firstPageItems.count(); const secondPageCount = await secondPageItems.count(); // First page should contain at least one item expect(firstPageCount).toBeGreaterThanOrEqual(1); // Second page should contain remaining items expect(secondPageCount).toBe(TOTAL_ITEMS - firstPageCount); }); test("all list items have same bounding box", async (page) => { const allItems = page.locator("li"); await expect(allItems).toHaveCount(TOTAL_ITEMS); const firstItemBox = await allItems.first().boundingBox(); expect(firstItemBox).not.toBeNull(); for (const item of await allItems.all()) { const itemBox = await item.boundingBox(); expect(itemBox).not.toBeNull(); expect(itemBox!.width).toBeCloseTo(firstItemBox!.width, 0); expect(itemBox!.height).toBeCloseTo(firstItemBox!.height, 0); } }); ================================================ FILE: quarkdown-html/src/test/e2e/live-preview/live-preview.spec.ts ================================================ import {expect, test} from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; import {DocumentType} from "../__util/paths"; import {runLivePreviewTest} from "../__util/live-preview-runner"; const TEST_DIR = __dirname; /** Timeout for each individual test (compilation + reload cycles are slow). */ const TEST_TIMEOUT = 90_000; /** Timeout for waiting for reloaded content to appear after an edit. */ const RELOAD_TIMEOUT = 30_000; const DOC_TYPES: DocumentType[] = ["plain", "paged", "slides", "docs"]; /** * Reads the original main.qd content (without doctype) for building edited versions. */ function readOriginalMain(): string { return fs.readFileSync(path.join(TEST_DIR, "main.qd"), "utf-8"); } /** * Returns the original main.qd content with "Marker Alpha" replaced by a different marker. */ function mainWithMarker(marker: string): string { return readOriginalMain().replace("Marker Alpha", marker); } // --- Source file edit triggers reload --- for (const docType of DOC_TYPES) { test(`source edit triggers reload [${docType}]`, async ({page}) => { test.setTimeout(TEST_TIMEOUT); await runLivePreviewTest( TEST_DIR, page, async (ctx) => { // Verify initial content is loaded. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Alpha", { timeout: RELOAD_TIMEOUT, }); // Edit main.qd with a new marker. ctx.editFile("main.qd", mainWithMarker("Marker Beta")); // The reload should eventually show the new marker. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Beta", { timeout: RELOAD_TIMEOUT, }); }, {docType}, ); }); } // --- Subdocument edit triggers reload --- for (const docType of DOC_TYPES) { test(`subdocument edit triggers reload [${docType}]`, async ({page}) => { test.setTimeout(TEST_TIMEOUT); await runLivePreviewTest( TEST_DIR, page, async (ctx) => { // Verify initial subdocument content is loaded. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Gamma", { timeout: RELOAD_TIMEOUT, }); // Edit sub.qd with a new marker. ctx.editFile("sub.qd", "Marker Delta\n"); // The reload should eventually show the new marker. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Delta", { timeout: RELOAD_TIMEOUT, }); }, {docType, subpath: "sub/index.html"}, ); }); } // --- Scroll position preserved across reload --- // Slides use Reveal.js which handles scrolling internally, so window.scrollY doesn't apply. const SCROLLABLE_DOC_TYPES: DocumentType[] = ["plain", "paged", "docs"]; for (const docType of SCROLLABLE_DOC_TYPES) { test(`scroll position preserved across reload [${docType}]`, async ({page}) => { test.setTimeout(TEST_TIMEOUT); await runLivePreviewTest( TEST_DIR, page, async (ctx) => { // Wait for initial content. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Alpha", { timeout: RELOAD_TIMEOUT, }); // Scroll the active iframe to a specific position. const visibleIframe = page.locator("iframe.visible"); const iframeHandle = await visibleIframe.elementHandle({timeout: 5000}); const contentFrame = await iframeHandle!.contentFrame(); // Scroll down and wait for it to settle. await contentFrame!.evaluate(() => window.scrollTo(0, 400)); await page.waitForTimeout(500); // Verify scroll position was applied. const scrollBefore = await contentFrame!.evaluate(() => window.scrollY); expect(scrollBefore).toBeGreaterThan(100); // Edit main.qd to trigger a reload. ctx.editFile("main.qd", mainWithMarker("Marker Beta")); // Wait for the reload to complete. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Beta", { timeout: RELOAD_TIMEOUT, }); // Check scroll position on the now-active iframe (may have swapped). const newIframeHandle = await page.locator("iframe.visible").elementHandle({timeout: 5000}); const newContentFrame = await newIframeHandle!.contentFrame(); const scrollAfter = await newContentFrame!.evaluate(() => window.scrollY); // Scroll position should be approximately preserved (within tolerance). expect(scrollAfter).toBeGreaterThan(100); expect(Math.abs(scrollAfter - scrollBefore)).toBeLessThan(200); }, {docType}, ); }); } // --- Sticky-to-bottom scroll preserved across reload --- for (const docType of SCROLLABLE_DOC_TYPES) { test(`sticky bottom scroll preserved across reload [${docType}]`, async ({page}) => { test.setTimeout(TEST_TIMEOUT); await runLivePreviewTest( TEST_DIR, page, async (ctx) => { // Wait for initial content. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Alpha", { timeout: RELOAD_TIMEOUT, }); // Scroll the active iframe to the very bottom. const visibleIframe = page.locator("iframe.visible"); const iframeHandle = await visibleIframe.elementHandle({timeout: 5000}); const contentFrame = await iframeHandle!.contentFrame(); await contentFrame!.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(500); // Verify we are at the bottom. const isAtBottom = await contentFrame!.evaluate(() => { return window.scrollY >= document.body.scrollHeight - window.innerHeight - 1; }); expect(isAtBottom).toBe(true); // Edit main.qd to trigger a reload. ctx.editFile("main.qd", mainWithMarker("Marker Beta")); // Wait for the reload to complete. await expect(ctx.activeFrame.locator("body")).toContainText("Marker Beta", { timeout: RELOAD_TIMEOUT, }); // Check that the new frame is still at the bottom. const newIframeHandle = await page.locator("iframe.visible").elementHandle({timeout: 5000}); const newContentFrame = await newIframeHandle!.contentFrame(); const isStillAtBottom = await newContentFrame!.evaluate(() => { return window.scrollY >= document.body.scrollHeight - window.innerHeight - 1; }); expect(isStillAtBottom).toBe(true); }, {docType}, ); }); } ================================================ FILE: quarkdown-html/src/test/e2e/live-preview/main.qd ================================================ Marker Alpha # Section 1 Paragraph one content for testing scroll behavior in live preview mode. Paragraph two content for testing scroll behavior in live preview mode. Paragraph three content for testing scroll behavior in live preview mode. # Section 2 Paragraph four content for testing scroll behavior in live preview mode. Paragraph five content for testing scroll behavior in live preview mode. Paragraph six content for testing scroll behavior in live preview mode. # Section 3 Paragraph seven content for testing scroll behavior in live preview mode. Paragraph eight content for testing scroll behavior in live preview mode. Paragraph nine content for testing scroll behavior in live preview mode. # Section 4 Paragraph ten content for testing scroll behavior in live preview mode. Paragraph eleven content for testing scroll behavior in live preview mode. Paragraph twelve content for testing scroll behavior in live preview mode. # Section 5 Paragraph thirteen content for testing scroll behavior in live preview mode. Paragraph fourteen content for testing scroll behavior in live preview mode. Paragraph fifteen content for testing scroll behavior in live preview mode. # Section 6 Paragraph sixteen content for testing scroll behavior in live preview mode. Paragraph seventeen content for testing scroll behavior in live preview mode. Paragraph eighteen content for testing scroll behavior in live preview mode. # Section 7 Paragraph nineteen content for testing scroll behavior in live preview mode. Paragraph twenty content for testing scroll behavior in live preview mode. Paragraph twenty-one content for testing scroll behavior in live preview mode. # Section 8 Paragraph twenty-two content for testing scroll behavior in live preview mode. Paragraph twenty-three content for testing scroll behavior in live preview mode. Paragraph twenty-four content for testing scroll behavior in live preview mode. # Section 9 Paragraph twenty-five content for testing scroll behavior in live preview mode. Paragraph twenty-six content for testing scroll behavior in live preview mode. Paragraph twenty-seven content for testing scroll behavior in live preview mode. # Section 10 Paragraph twenty-eight content for testing scroll behavior in live preview mode. Paragraph twenty-nine content for testing scroll behavior in live preview mode. Paragraph thirty content for testing scroll behavior in live preview mode. [Sub page](sub.qd) ================================================ FILE: quarkdown-html/src/test/e2e/live-preview/sub.qd ================================================ Marker Gamma ================================================ FILE: quarkdown-html/src/test/e2e/margin-reset/first-in-page/first-in-page.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "first heading on each page has zero margin-top", ["paged"], async (page) => { const pages = page.locator(".pagedjs_page"); await expect(pages).toHaveCount(3); // Page 1: # 1 (first, margin-top = 0), # 2 (not first), ## 3 (not first) const page1Headings = pages.nth(0).locator(":is(h1, h2)"); await expect(page1Headings).toHaveCount(3); await expect(page1Headings.nth(0)).toHaveCSS("margin-top", "0px"); await expect(page1Headings.nth(1)).not.toHaveCSS("margin-top", "0px"); await expect(page1Headings.nth(2)).not.toHaveCSS("margin-top", "0px"); // Page 2: # 4 (first, margin-top = 0) const page2Headings = pages.nth(1).locator(":is(h1, h2)"); await expect(page2Headings).toHaveCount(1); await expect(page2Headings.nth(0)).toHaveCSS("margin-top", "0px"); // Page 3: ## 5 (first, margin-top = 0) const page3Headings = pages.nth(2).locator(":is(h1, h2)"); await expect(page3Headings).toHaveCount(1); await expect(page3Headings.nth(0)).toHaveCSS("margin-top", "0px"); }, ); ================================================ FILE: quarkdown-html/src/test/e2e/margin-reset/first-in-page/main.qd ================================================ .noautopagebreak # 1 # 2 ## 3 <<< # 4 <<< ## 5 ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_misaligned-in-block/bulletin.yml ================================================ - - issue: 144 - pr: 145 - next-release: 1.7.0 - cause: margin applied to `blockquote :first-child` and `:last-child` in `latex` theme - - issue: 193 - pr: 195 - next-release: 1.9.1 - cause: margin applied to `box-content :last-child` ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_misaligned-in-block/main.qd ================================================ > $ P_1 = P_0 \times \frac{S_1^2}{S_0^2} $ .box {title} type:{note} $ P_1 = P_0 \times \frac{S_1^2}{S_0^2} $ $ P_1 = P_0 \times \frac{S_1^2}{S_0^2} $ ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_misaligned-in-block/misaligned-in-parent.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("math in quote and outside have same rendering", async (page) => { const mathInQuote = page.locator("blockquote formula"); const mathInBox = page.locator(".box formula"); const mathOutside = page.locator("formula:not(blockquote formula, .box formula)"); await expect(mathInQuote).toBeAttached(); await expect(mathInBox).toBeAttached(); await expect(mathOutside).toBeAttached(); // Both should have the same inner structure const quoteChildren = await mathInQuote.locator(".katex-html *").all(); const boxChildren = await mathInBox.locator(".katex-html *").all(); const outsideChildren = await mathOutside.locator(".katex-html *").all(); expect(quoteChildren.length).toBe(outsideChildren.length); expect(boxChildren.length).toBe(outsideChildren.length); let quoteOffset: number | null = null; let boxOffset: number | null = null; // Ensure relative offsets are consistent for (let i = 0; i < quoteChildren.length; i++) { const quoteBox = await quoteChildren[i].boundingBox(); const boxBox = await boxChildren[i].boundingBox(); const outsideBox = await outsideChildren[i].boundingBox(); expect(quoteBox).not.toBeNull(); expect(boxBox).not.toBeNull(); expect(outsideBox).not.toBeNull(); if (quoteOffset === null) { quoteOffset = quoteBox!.x - outsideBox!.x; } else { expect(quoteBox!.x - outsideBox!.x).toBeCloseTo(quoteOffset, 0); } if (boxOffset === null) { boxOffset = boxBox!.x - outsideBox!.x; } else { expect(boxBox!.x - outsideBox!.x).toBeCloseTo(boxOffset, 0); } expect(quoteBox!.height).toBeCloseTo(outsideBox!.height, 0); } }); ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_paged-overflow/bulletin.yml ================================================ - issue: 134 - pr: 151 - next-release: 1.7.0 - cause: math rendered in post-rendering rather than pre-rendering ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_paged-overflow/issue_paged-overflow.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("math does not overflow to next page incorrectly", async (page) => { const pages = page.locator(".pagedjs_page"); await expect(pages).toHaveCount(2); const firstPage = pages.nth(0); const secondPage = pages.nth(1); // First page contains first math and container const firstPageMath = firstPage.locator("formula"); const firstPageParagraphs = firstPage.locator("p"); await expect(firstPageMath).toHaveCount(1); await expect(firstPageParagraphs).toHaveCount(5); // Second page contains second math const secondPageMath = secondPage.locator("formula"); const secondPageParagraphs = secondPage.locator("p"); await expect(secondPageMath).toHaveCount(1); await expect(secondPageParagraphs).toHaveCount(0); }); ================================================ FILE: quarkdown-html/src/test/e2e/math/issue_paged-overflow/main.qd ================================================ .doctype {paged} $$$ \begin{cases} x+y+z=1\\ 2x-y+z=2\\ x-2y+3z=3 \end{cases} $$$ .repeat {5} .loremipsum $$$ \begin{cases} x+y+z=1\\ 2x-y+z=2\\ x-2y+3z=3 \end{cases} $$$ ================================================ FILE: quarkdown-html/src/test/e2e/math/main.qd ================================================ $ f(x) = \frac{1}{x} $ Inline: $ f(x) = \frac{1}{x} $ ================================================ FILE: quarkdown-html/src/test/e2e/math/math.spec.ts ================================================ import {getComputedColor} from "../__util/css"; import {suite} from "../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders block and inline math correctly", ["plain", "paged", "slides", "docs"], async (page) => { const blockFormula = page.locator("formula[data-block] > .katex-display"); const inlineFormula = page.locator("p formula > .katex"); await expect(blockFormula).toBeAttached(); await expect(inlineFormula).toBeAttached(); const mainColor = await getComputedColor(page, "var(--qd-main-color)"); await expect(blockFormula).toHaveCSS("color", mainColor); await expect(inlineFormula).toHaveCSS("color", mainColor); const blockBox = await blockFormula.boundingBox(); const inlineBox = await inlineFormula.boundingBox(); expect(blockBox).not.toBeNull(); expect(inlineBox).not.toBeNull(); expect(blockBox!.width).toBeGreaterThan(inlineBox!.width); expect(blockBox!.y).toBeLessThan(inlineBox!.y); const blockBaseBox = await blockFormula.locator(".base").first().boundingBox(); const inlineBaseBox = await inlineFormula.locator(".base").first().boundingBox(); expect(blockBaseBox!.x).toBeGreaterThan(inlineBaseBox!.x); // Ensure no component has text-align or text-align-last set to `justify` const allElements = page.locator("formula .katex *"); const count = await allElements.count(); for (let i = 0; i < count; i++) { const element = allElements.nth(i); await expect(element).not.toHaveCSS("text-align", "justify"); await expect(element).not.toHaveCSS("text-align-last", "justify"); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/media/main.qd ================================================ ![](../__assets/rect.png) ================================================ FILE: quarkdown-html/src/test/e2e/media/media.spec.ts ================================================ import {existsSync} from "fs"; import {join} from "path"; import {outputDir, suite} from "../quarkdown"; const {test, expect} = suite(__dirname); test("renders image from media storage", async (page) => { const img = page.locator("img"); await expect(img).toBeAttached(); // Image source should be transformed to media/rect@HASH.png const src = await img.getAttribute("src"); expect(src).toMatch(/^media\/rect@[-\d]+\.png$/); // Media file should exist on disk const mediaPath = join(outputDir(__dirname), src!); expect(existsSync(mediaPath)).toBe(true); // Image should be visible and have non-zero dimensions await expect(img).toBeVisible(); const box = await img.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThan(0); expect(box!.height).toBeGreaterThan(0); }); ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/class/class.mmd ================================================ classDiagram class Bank { +name: string +address: string } class Customer { +name: string +id: int } class BankAccount { +id: int +balance: double +deposit(amount: double) +withdraw(amount: double) } class Transaction { +amount: double +date: date +execute() } class Loan { +id: int +amount: double +interestRate: double +approve() } Bank "1" o-- "*" Customer : manages Customer "1" --> "*" BankAccount : owns Customer "1" --> "*" Loan : has BankAccount "1" --> "*" Transaction : records ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/class/class.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {assertMermaidBase, assertSvgWidthRatio} from "../index"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders class diagram correctly", ["plain", "paged", "slides"], async (page) => { const {pre, svg} = await assertMermaidBase(page); // Get computed RGB values for CSS variables const borderColor = await getComputedColor(page, "var(--qd-mermaid-node-border-color)"); const bgColor = await getComputedColor(page, "var(--qd-mermaid-node-background-color)"); // At least one path or text should have the expected colors const paths = svg.locator("path"); const texts = svg.locator("text"); let hasMatchingColor = false; const pathCount = await paths.count(); for (let i = 0; i < pathCount && !hasMatchingColor; i++) { const stroke = await paths.nth(i).evaluate((el) => getComputedStyle(el).stroke); const fill = await paths.nth(i).evaluate((el) => getComputedStyle(el).fill); if (stroke === borderColor || fill === bgColor) { hasMatchingColor = true; } } const textCount = await texts.count(); for (let i = 0; i < textCount && !hasMatchingColor; i++) { const stroke = await texts.nth(i).evaluate((el) => getComputedStyle(el).stroke); const fill = await texts.nth(i).evaluate((el) => getComputedStyle(el).fill); if (stroke === borderColor || fill === bgColor) { hasMatchingColor = true; } } expect(hasMatchingColor).toBe(true); // SVG width is between 50-60% of parent await assertSvgWidthRatio(pre, svg, 0.5, 0.6); } ); ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/class/main.qd ================================================ .mermaid .read {class.mmd} ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/index.ts ================================================ import {expect, Page} from "@playwright/test"; /** * Common assertions for Mermaid diagrams. * Returns the pre and svg locators for additional assertions. */ export async function assertMermaidBase(page: Page) { const pre = page.locator("pre.mermaid"); await expect(pre).toBeAttached(); // Has data-processed attribute await expect(pre).toHaveAttribute("data-processed", "true"); // Has inlined width: 100% const style = await pre.getAttribute("style"); expect(style).toContain("width: 100%"); // Only child is svg const svg = pre.locator("> svg"); await expect(svg).toBeAttached(); await expect(pre.locator("> *")).toHaveCount(1); // SVG has id mermaid-HASH const svgId = await svg.getAttribute("id"); expect(svgId).toMatch(/^mermaid-[-\w]+$/i); return {pre, svg}; } /** * Asserts that the SVG width is within a given percentage range of its parent. */ export async function assertSvgWidthRatio( pre: ReturnType<Page["locator"]>, svg: ReturnType<Page["locator"]>, min: number, max: number ) { const preBox = await pre.boundingBox(); const svgBox = await svg.boundingBox(); expect(preBox).not.toBeNull(); expect(svgBox).not.toBeNull(); const widthRatio = svgBox!.width / preBox!.width; expect(widthRatio).toBeGreaterThanOrEqual(min); expect(widthRatio).toBeLessThanOrEqual(max); } ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/pie/main.qd ================================================ .mermaid .read {pie.mmd} ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/pie/pie.mmd ================================================ pie showData "Sleep" : 8 "Work" : 9 "Exercise" : 1 "Leisure" : 4 "Meals" : 2 ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/pie/pie.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {assertMermaidBase, assertSvgWidthRatio} from "../index"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders pie chart correctly", ["plain", "paged", "slides"], async (page) => { const {pre, svg} = await assertMermaidBase(page); // Get computed RGB values for CSS variables const lineColor = await getComputedColor(page, "var(--qd-mermaid-node-line-color)"); const bgColor = await getComputedColor(page, "var(--qd-mermaid-node-background-color)"); // Legend text must have fill color --qd-mermaid-node-line-color const legendText = svg.locator(".legend text").first(); await expect(legendText).toHaveCSS("fill", lineColor); // No path or rect should have fill color --qd-mermaid-node-background-color const paths = svg.locator("path"); const rects = svg.locator("rect"); const pathCount = await paths.count(); for (let i = 0; i < pathCount; i++) { const fill = await paths.nth(i).evaluate((el) => getComputedStyle(el).fill); expect(fill).not.toBe(bgColor); } const rectCount = await rects.count(); for (let i = 0; i < rectCount; i++) { const fill = await rects.nth(i).evaluate((el) => getComputedStyle(el).fill); expect(fill).not.toBe(bgColor); } // SVG width is between 60-70% of parent await assertSvgWidthRatio(pre, svg, 0.6, 0.7); } ); ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/xy/main.qd ================================================ .xychart - - 1 - 3 - - 4 - 2 ================================================ FILE: quarkdown-html/src/test/e2e/mermaid/xy/xy.spec.ts ================================================ import {getComputedColor} from "../../__util/css"; import {assertMermaidBase, assertSvgWidthRatio} from "../index"; import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders xy chart correctly", ["plain", "paged", "slides"], async (page) => { const {pre, svg} = await assertMermaidBase(page); // Get computed RGB values for CSS variables const linkColor = await getComputedColor(page, "var(--qd-link-color)"); const borderColor = await getComputedColor(page, "var(--qd-mermaid-node-border-color)"); // Line plot stroke matches --qd-link-color const linePlotPath = svg.locator(".line-plot-0 > path"); await expect(linePlotPath).toHaveCSS("stroke", linkColor); // Axis elements match --qd-border-color const axisPath = svg.locator(":is(.left-axis, .bottom-axis) path").first(); const axisText = svg.locator(":is(.left-axis, .bottom-axis) text").first(); await expect(axisPath).toHaveCSS("stroke", borderColor); await expect(axisText).toHaveCSS("fill", borderColor); // SVG width is between 60-80% of parent await assertSvgWidthRatio(pre, svg, 0.6, 0.8); } ); ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/all-columns-by-page-fill/all-columns-by-page-fill.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "all pages have both columns filled", ["paged"], async (page) => { const pages = page.locator(".pagedjs_page"); const pageCount = await pages.count(); expect(pageCount).toBeGreaterThan(1); // The last page may not fill both columns, so only check pages before it. for (let i = 0; i < pageCount - 1; i++) { const paragraphs = pages.nth(i).locator("p"); const count = await paragraphs.count(); expect(count).toBeGreaterThan(0); // Collect distinct column x-positions (with a tolerance for rounding). const columnXs: number[] = []; for (let j = 0; j < count; j++) { const box = await paragraphs.nth(j).boundingBox(); if (box && !columnXs.some((x) => Math.abs(x - box.x) < 5)) { columnXs.push(box.x); } } expect(columnXs, `page ${i + 1} should have exactly 2 columns`).toHaveLength(2); } }, ); ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/all-columns-by-page-fill/main.qd ================================================ .pageformat columns:{2} .repeat {50} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/full-span/full-span.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); /** * Collects distinct column x-positions from a set of locators, with a tolerance for rounding. */ async function distinctColumnCount(locator: import("@playwright/test").Locator): Promise<number> { const xs: number[] = []; const count = await locator.count(); for (let i = 0; i < count; i++) { const box = await locator.nth(i).boundingBox(); if (box && !xs.some((x) => Math.abs(x - box.x) < 5)) { xs.push(box.x); } } return xs.length; } testMatrix( "full-span elements span across all columns", ["plain", "paged", "slides", "slides-print", "docs"], async (page) => { const paragraphs = page.locator("p"); const h2 = page.locator("h2"); const fullSpan = page.locator(".full-column-span"); // Paragraphs are laid out in two columns. expect(await distinctColumnCount(paragraphs)).toBe(2); // h2 has column-span: all and spans the full parent width. await expect(h2).toHaveCSS("column-span", "all"); const h2Box = (await h2.boundingBox())!; const parentBox = (await h2.locator("..").boundingBox())!; expect(h2Box.width).toBeGreaterThan(parentBox.width * 0.9); // .full-column-span has the same span properties as the h2. await expect(fullSpan).toHaveCSS("column-span", "all"); const fullSpanBox = (await fullSpan.boundingBox())!; expect(fullSpanBox.width).toBeGreaterThan(parentBox.width * 0.9); }, ); ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/full-span/main.qd ================================================ .pageformat columns:{2} .repeat {2} .loremipsum ## Full span .repeat {2} .loremipsum .fullspan Full span .repeat {2} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/only-first-column/main.qd ================================================ .pageformat columns:{2} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/multicolumn/only-first-column/only-first-column.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "2-column paragraph fills only the first column", ["plain", "paged", "slides", "docs"], async (page) => { const paragraph = page.locator("p").first(); await expect(paragraph).toBeVisible(); const paragraphBox = await paragraph.boundingBox(); expect(paragraphBox).not.toBeNull(); // The paragraph should occupy roughly half the available width, since it fills only the first of two columns. const viewportWidth = page.viewportSize()!.width; expect(paragraphBox!.width).toBeLessThan(viewportWidth * 0.6); }, ); ================================================ FILE: quarkdown-html/src/test/e2e/numbering/default/default.spec.ts ================================================ import {getBeforeContent, isBeforeInline} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies numbering to all element types", async (page) => { // Headings: 1.1 format (h1=1, h2=1.1, h3 not numbered) const h1 = page.locator("h1").first(); expect(await getBeforeContent(h1)).toContain("1"); expect(await isBeforeInline(h1)).toBe(true); expect(await getBeforeContent(page.locator("h2").first())).toContain("1.1"); // Figures: 1.a format const figures = page.locator("figure[id^='figure-'] figcaption"); await expect(figures).toHaveCount(4); expect(await getBeforeContent(figures.nth(0))).toContain("0.a"); expect(await isBeforeInline(figures.nth(0))).toBe(true); expect(await getBeforeContent(figures.nth(1))).toContain("1.a"); expect(await getBeforeContent(figures.nth(2))).toContain("1.b"); expect(await getBeforeContent(figures.nth(3))).toContain("1.c"); // Code: 1 format const codeBlocks = page.locator("figure[id^='listing-'] figcaption"); await expect(codeBlocks).toHaveCount(2); expect(await getBeforeContent(codeBlocks.nth(0))).toContain("1"); expect(await getBeforeContent(codeBlocks.nth(1))).toContain("2"); // Equations: (1.A) format const equations = page.locator("formula[data-block]"); await expect(equations.nth(0)).toHaveAttribute("data-location", "(1.A)"); await expect(equations.nth(1)).toHaveAttribute("data-location", "(1.B)"); // Tables: i format const tableCaption = page.locator("table caption"); expect(await getBeforeContent(tableCaption)).toContain("i"); // Footnotes: I format const footnoteRef = page.locator(".footnote-reference a"); await expect(footnoteRef).toHaveText("I"); }); ================================================ FILE: quarkdown-html/src/test/e2e/numbering/default/main.qd ================================================ .numbering - headings: 1.1 - figures: 1.a - tables: i - equations: (1.A) - code: 1 - footnotes: I .figure caption:{Fig 1} Fig # A $ Equation 1 $ {#_} ## B .figure caption:{Fig 2} Fig .figure caption:{Fig 3} Fig ```text Code 1 ``` ### C ```text Code 2 ``` $ Equation 2 $ {#_} | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | footnote[^1]. [^1]: This is footnote 1. .mermaid caption:{Fig 4} graph TD A-->B A-->C ================================================ FILE: quarkdown-html/src/test/e2e/numbering/localized/localized.spec.ts ================================================ import {getFullText} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("displays localized numbering labels", async (page) => { const figure = page.locator("figure[id^='figure-'] figcaption"); expect(await getFullText(figure)).toEqual("Figure 1: Fig"); const table = page.locator("table caption"); expect(await getFullText(table)).toEqual("Table 1: Table"); const code = page.locator("figure[id^='listing-'] figcaption"); expect(await getFullText(code)).toEqual("Listing 1: Code"); }); ================================================ FILE: quarkdown-html/src/test/e2e/numbering/localized/main.qd ================================================ .doclang {English} .numbering - figures: 1 - tables: 1 - code: 1 .figure caption:{Fig} Fig | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Table" .code lang:{text} caption:{Code} Code ================================================ FILE: quarkdown-html/src/test/e2e/page-break/main.qd ================================================ A <<< B # Title C ================================================ FILE: quarkdown-html/src/test/e2e/page-break/page-break.spec.ts ================================================ import {suite} from "../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders page breaks", ["paged", "slides", "slides-print"], async (page, docType) => { const expectedPageCount = 3; switch (docType) { case "paged": await expect(page.locator(".pagedjs_page")).toHaveCount(expectedPageCount); break; case "slides": case "slides-print": await expect(page.locator(".reveal .slides > *")).toHaveCount(expectedPageCount); await expect(page.locator(".reveal .slide-background")).toHaveCount(expectedPageCount); break; } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/alignment-global/alignment-global.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies global alignment correctly", ["plain", "paged", "slides"], async (page, docType) => { const heading = page.locator("h1").first(); const paragraph = page.locator("p").first(); const listItem = page.locator("li").first(); const alignment = "end"; await expect(heading).toHaveCSS("text-align", alignment); await expect(paragraph).toHaveCSS("text-align", alignment); await expect(paragraph).toHaveCSS("text-align-last", alignment); await expect(listItem).toHaveCSS("text-align", docType === "slides" ? "start" : "justify"); await expect(listItem).toHaveCSS("text-align-last", "start"); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/alignment-global/main.qd ================================================ .pageformat alignment:{end} # Title Content - Content - Content ================================================ FILE: quarkdown-html/src/test/e2e/page-format/alignment-local/alignment-local.spec.ts ================================================ import {suite} from "../../quarkdown"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies local alignment correctly", ["plain", "paged", "slides"], async (page, docType) => { const heading = page.locator("h1").first(); const paragraph = page.locator("p").first(); const listItem = page.locator("li").first(); const localAlignment = "justify"; const globalAlignment = "start"; await expect(heading).toHaveCSS("text-align", globalAlignment); await expect(paragraph).toHaveCSS("text-align", localAlignment); await expect(paragraph).toHaveCSS("text-align-last", globalAlignment); await expect(listItem).toHaveCSS("text-align", localAlignment); await expect(listItem).toHaveCSS("text-align-last", globalAlignment); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/alignment-local/main.qd ================================================ .pageformat alignment:{justify} # Title Content - Content - Content ================================================ FILE: quarkdown-html/src/test/e2e/page-format/border/border.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getPageSizeTarget} from "../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies page border correctly", ["plain", "paged", "slides", "slides-print"], async (page, docType) => { let target = getPageSizeTarget(page, docType); if (docType === "paged") { target = target.locator(".pagedjs_area"); } else if (docType === "slides-print") { target = target.locator(".pdf-page").first(); } await expect(target).toHaveCSS("border-top-width", "30px"); await expect(target).toHaveCSS("border-bottom-width", "5px"); await expect(target).toHaveCSS("border-left-width", "10px"); await expect(target).toHaveCSS("border-right-width", "40px"); const color = "rgb(255, 0, 0)"; await expect(target).toHaveCSS("border-top-color", color); await expect(target).toHaveCSS("border-bottom-color", color); await expect(target).toHaveCSS("border-left-color", color); await expect(target).toHaveCSS("border-right-color", color); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/border/main.qd ================================================ .pageformat bordercolor:{red} borderbottom:{5px} bordertop:{30px} borderleft:{10px} borderright:{40px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/index.ts ================================================ import {Locator, Page} from "@playwright/test"; import {DocumentType} from "../__util/paths"; // A4 dimensions: 210mm × 297mm export const A4_WIDTH_PX = 210 * 96 / 25.4; // ~793.7px export const A4_HEIGHT_PX = 297 * 96 / 25.4; // ~1122.52px // A5 dimensions: 148mm × 210mm export const A5_WIDTH_PX = 148 * 96 / 25.4; // ~559.37px export const A5_HEIGHT_PX = 210 * 96 / 25.4; // ~793.7px export function getPageSizeTarget(page: Page, docType: DocumentType): Locator { switch (docType) { case "plain": return page.locator("body > main"); case "docs": return page.locator(".content-wrapper > main"); case "slides": case "slides-print": return page.locator(".reveal"); case "paged": return page.locator(".pagedjs_page").first(); default: throw new Error(`Unsupported docType: ${docType}`); } } ================================================ FILE: quarkdown-html/src/test/e2e/page-format/margins/main.qd ================================================ .pageformat margin:{50px 100px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/margins/margins.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getPageSizeTarget} from "../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies page margins correctly", ["plain", "paged"], async (page, docType) => { if (docType === "paged") { const target= getPageSizeTarget(page, docType); const marginTop = target.locator(".pagedjs_margin-top"); const marginLeft = target.locator(".pagedjs_margin-left"); const marginTopBox = await marginTop.boundingBox(); const marginLeftBox = await marginLeft.boundingBox(); expect(marginTopBox).not.toBeNull(); expect(marginLeftBox).not.toBeNull(); // Vertical margin: 50px expect(marginTopBox!.height).toBeCloseTo(50, 0); // Horizontal margin: 100px expect(marginLeftBox!.width).toBeCloseTo(100, 0); } else { const target = page.locator("body"); await expect(target).toHaveCSS("margin-top", "50px"); await expect(target).toHaveCSS("margin-bottom", "50px"); await expect(target).toHaveCSS("margin-left", "100px"); await expect(target).toHaveCSS("margin-right", "100px"); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/range/main.qd ================================================ .doctype {paged} .pageformat size:{A4} bordercolor:{red} .pageformat pages:{2..3} margin:{3cm} bordercolor:{green} .repeat {20} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/range/range.spec.ts ================================================ import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); // The source defines: // - Global: A4, bordercolor:red // - Range 2..3: margin:3cm, bordercolor:green function getPage(page: import("@playwright/test").Page, index: number) { return page.locator(".pagedjs_page").nth(index); } test("page outside range has global border color", async (page) => { const firstPage = getPage(page, 0); const area = firstPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(255, 0, 0)"); }); test("page inside range has scoped border color", async (page) => { // Page index 1 = page number 2 (in range 2..3). const secondPage = getPage(page, 1); const area = secondPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(0, 128, 0)"); }); test("third page also has scoped border color", async (page) => { // Page index 2 = page number 3 (in range 2..3). const thirdPage = getPage(page, 2); const area = thirdPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(0, 128, 0)"); }); test("page after range reverts to global border color", async (page) => { // Page index 3 = page number 4 (outside range 2..3). const fourthPage = getPage(page, 3); const area = fourthPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(255, 0, 0)"); }); test("page inside range has larger margin", async (page) => { const firstPage = getPage(page, 0); const secondPage = getPage(page, 1); const firstMargin = firstPage.locator(".pagedjs_margin-left"); const secondMargin = secondPage.locator(".pagedjs_margin-left"); const firstBox = await firstMargin.boundingBox(); const secondBox = await secondMargin.boundingBox(); expect(firstBox).not.toBeNull(); expect(secondBox).not.toBeNull(); // Range pages (2..3) have 3cm margin, which is larger than the default. expect(secondBox!.width).toBeGreaterThan(firstBox!.width); }); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/range-side/main.qd ================================================ .doctype {paged} .pageformat size:{A4} borderleft:{1cm} bordercolor:{red} .pageformat side:{left} bordercolor:{blue} .pageformat pages:{2..4} bordercolor:{green} .pageformat side:{right} pages:{2..4} bordercolor:{purple} .repeat {30} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/range-side/range-side.spec.ts ================================================ import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); // The source layers four scopes: // 1. Global: borderleft:1cm, bordercolor:red // 2. side:left: bordercolor:blue // 3. range:2..4: bordercolor:green // 4. side:right + range:2..4: bordercolor:purple // // Page 1 is right (recto), page 2 is left, page 3 is right, page 4 is left, page 5 is right. // // Expected border-left-color per page: // Page 1 (right, outside range): red (global) // Page 2 (left, in range): green (range overrides side) // Page 3 (right, in range): purple (side+range) // Page 4 (left, in range): green (range, not right so side+range doesn't apply) // Page 5 (right, outside range): red (global) function getPage(page: import("@playwright/test").Page, index: number) { return page.locator(".pagedjs_page").nth(index); } function getArea(page: import("@playwright/test").Page, index: number) { return getPage(page, index).locator(".pagedjs_area"); } test("page 1 (right, outside range) has global red border", async (page) => { await expect(getArea(page, 0)).toHaveCSS("border-left-color", "rgb(255, 0, 0)"); }); test("page 2 (left, in range) has range green border", async (page) => { await expect(getArea(page, 1)).toHaveCSS("border-left-color", "rgb(0, 128, 0)"); }); test("page 3 (right, in range) has side+range purple border", async (page) => { await expect(getArea(page, 2)).toHaveCSS("border-left-color", "rgb(128, 0, 128)"); }); test("page 4 (left, in range) has range green border", async (page) => { await expect(getArea(page, 3)).toHaveCSS("border-left-color", "rgb(0, 128, 0)"); }); test("page 5 (right, outside range) reverts to global red border", async (page) => { await expect(getArea(page, 4)).toHaveCSS("border-left-color", "rgb(255, 0, 0)"); }); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/side/main.qd ================================================ .doctype {paged} .pageformat alignment:{center} .pageformat margin:{5cm} borderleft:{1cm} bordercolor:{red} .pageformat side:{left} margin:{1cm} bordercolor:{green} .pageformat side:{left} alignment:{end} .pageformat side:{right} bordercolor:{blue} .repeat {20} .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/page-format/scoped/side/side.spec.ts ================================================ import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); // The source defines: // - Global: alignment:center, margin:5cm, borderleft:1cm, bordercolor:red // - Left: margin:1cm, bordercolor:green, alignment:end // - Right: bordercolor:blue // Helper to get the first page of each side. function getLeftPage(page: import("@playwright/test").Page) { return page.locator(".pagedjs_left_page").first(); } function getRightPage(page: import("@playwright/test").Page) { return page.locator(".pagedjs_right_page").first(); } test("global format applies border and alignment to all pages", async (page) => { // The first page is a right page in paged.js (recto). const firstPage = page.locator(".pagedjs_page").first(); const area = firstPage.locator(".pagedjs_area"); // Global border: left 1cm, color red. await expect(area).toHaveCSS("border-left-style", "solid"); }); test("left page has green border color", async (page) => { const leftPage = getLeftPage(page); const area = leftPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(0, 128, 0)"); }); test("right page has blue border color", async (page) => { const rightPage = getRightPage(page); const area = rightPage.locator(".pagedjs_area"); await expect(area).toHaveCSS("border-left-color", "rgb(0, 0, 255)"); }); test("left page has end alignment", async (page) => { const leftPage = getLeftPage(page); const paragraph = leftPage.locator("p").first(); await expect(paragraph).toHaveCSS("text-align", "end"); }); test("right page inherits global center alignment", async (page) => { const rightPage = getRightPage(page); const paragraph = rightPage.locator("p").first(); await expect(paragraph).toHaveCSS("text-align", "center"); }); test("left page has smaller margin than global", async (page) => { const leftPage = getLeftPage(page); const rightPage = getRightPage(page); const leftMargin = leftPage.locator(".pagedjs_margin-left"); const rightMargin = rightPage.locator(".pagedjs_margin-left"); const leftBox = await leftMargin.boundingBox(); const rightBox = await rightMargin.boundingBox(); expect(leftBox).not.toBeNull(); expect(rightBox).not.toBeNull(); // Left page margin is 1cm (~37.8px), global margin is 5cm (~189px). expect(leftBox!.width).toBeLessThan(rightBox!.width); }); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/format/format.spec.ts ================================================ import {suite} from "../../../quarkdown"; import {A5_HEIGHT_PX, A5_WIDTH_PX, getPageSizeTarget} from "../../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies A5 format with width override", ["paged", "slides"], async (page, docType) => { const target = getPageSizeTarget(page, docType); const isPortrait = docType === "paged"; const box = await target.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeCloseTo(isPortrait ? A5_WIDTH_PX : A5_HEIGHT_PX, 0); expect(box!.height).toBeCloseTo(isPortrait ? A5_HEIGHT_PX : A5_WIDTH_PX, 0); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/format/main.qd ================================================ .pageformat size:{A5} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/format-and-width/format-and-width.spec.ts ================================================ import {suite} from "../../../quarkdown"; import {A5_HEIGHT_PX, A5_WIDTH_PX, getPageSizeTarget} from "../../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies default format with width override", ["paged", "slides"], async (page, docType) => { const target = getPageSizeTarget(page, docType); const isPortrait = docType === "paged"; await expect(target).toHaveCSS("width", "100px"); const box = await target.boundingBox(); expect(box).not.toBeNull(); expect(box!.height).toBeCloseTo(isPortrait ? A5_HEIGHT_PX : A5_WIDTH_PX, 0); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/format-and-width/main.qd ================================================ .pageformat size:{A5} width:{100px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/height/height.spec.ts ================================================ import {suite} from "../../../quarkdown"; import {A4_WIDTH_PX, getPageSizeTarget} from "../../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies page height to correct element", ["paged", "slides"], async (page, docType) => { const target = getPageSizeTarget(page, docType); await expect(target).toHaveCSS("height", "100px"); // In paged, unset width defaults to A4 width if (docType === "paged") { const box = await target.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeCloseTo(A4_WIDTH_PX, 0); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/height/main.qd ================================================ .pageformat height:{100px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/width/main.qd ================================================ .pageformat width:{100px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/width/width.spec.ts ================================================ import {suite} from "../../../quarkdown"; import {A4_HEIGHT_PX, getPageSizeTarget} from "../../index"; const {testMatrix, expect} = suite(__dirname); const EXPECTED_WIDTH = 100; testMatrix( "applies page width to correct element", ["plain", "paged", "slides", "docs"], async (page, docType) => { const target = getPageSizeTarget(page, docType); switch (docType) { case "paged": // In paged, unset height defaults to A4 height const box = await target.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeCloseTo(EXPECTED_WIDTH, 0); expect(box!.height).toBeCloseTo(A4_HEIGHT_PX, 0); break; case "plain": case "docs": for (let i = 0; i < 2; i++) { const isPrint = i !== 0; if (isPrint) { await page.emulateMedia({media: "print"}); } const targetBox = await target.boundingBox(); const body = page.locator("body"); const bodyBox = await body.boundingBox(); expect(bodyBox).not.toBeNull(); if (isPrint) { expect(targetBox!.width).not.toBeCloseTo(EXPECTED_WIDTH, 0); expect(targetBox!.width).toBeCloseTo(bodyBox!.width, -2); } else { expect(targetBox!.width).toBeCloseTo(EXPECTED_WIDTH, 0); expect(targetBox!.width).not.toBeCloseTo(bodyBox!.width, -2); } } break; case "slides": const slide = await target.boundingBox(); expect(slide).not.toBeNull(); expect(slide!.width).toBeCloseTo(EXPECTED_WIDTH, 0); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/width-and-height/main.qd ================================================ .pageformat width:{100px} height:{100px} # 1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-format/size/width-and-height/width-and-height.spec.ts ================================================ import {suite} from "../../../quarkdown"; import {getPageSizeTarget} from "../../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "applies both width and height", ["paged", "slides"], async (page, docType) => { const target = getPageSizeTarget(page, docType); await expect(target).toHaveCSS("width", "100px"); await expect(target).toHaveCSS("height", "100px"); } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/all-pages/all-pages.spec.ts ================================================ import {suite} from "../../quarkdown"; import {DOCS_CONTAINERS, getPageContainers, MARGIN_NAMES, MARGIN_SUFFIX} from "../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders page margins correctly", ["paged", "slides", "slides-print", "docs"], async (page, docType) => { async function assertMarginContent(marginContent: ReturnType<typeof page.locator>, text: string) { await expect(marginContent).toBeAttached(); const paragraph = marginContent.locator("> p"); await expect(paragraph).toBeAttached(); await expect(paragraph).toHaveText(text); await expect(marginContent.locator("> *")).toHaveCount(1); } // Docs: single instance per margin, no pagination if (docType === "docs") { for (const marginName of MARGIN_NAMES) { const suffix = MARGIN_SUFFIX[marginName]; const containerSelector = DOCS_CONTAINERS[suffix]; const container = page.locator(containerSelector); const marginContent = container.locator(`.page-margin-${suffix}.page-margin-content`); await assertMarginContent(marginContent, marginName); } return; } // Paged and slides: 3 pages/slides with margins on each const containers = getPageContainers(page, docType); await expect(containers).toHaveCount(3); for (const marginName of MARGIN_NAMES) { const suffix = MARGIN_SUFFIX[marginName]; for (let i = 0; i < 3; i++) { const container = containers.nth(i); if (docType === "paged") { const margin = container.locator(`.pagedjs_margin-${suffix}`); await expect(margin).toHaveClass(/hasContent/); const content = margin.locator("> .pagedjs_margin-content"); await expect(content).toBeAttached(); await expect(content).toHaveClass(/page-margin-content/); await expect(margin.locator("> *")).toHaveCount(1); await assertMarginContent(content, marginName); } else { const marginContent = container.locator(`.page-margin-${suffix}.page-margin-content`); await assertMarginContent(marginContent, marginName); } } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/all-pages/main.qd ================================================ .pagemargin {topleftcorner} topleftcorner .pagemargin {topleft} topleft .pagemargin {topcenter} topcenter .pagemargin {topright} topright .pagemargin {toprightcorner} toprightcorner .pagemargin {righttop} righttop .pagemargin {rightmiddle} rightmiddle .pagemargin {rightbottom} rightbottom .pagemargin {bottomrightcorner} bottomrightcorner .pagemargin {bottomright} bottomright .pagemargin {bottomcenter} bottomcenter .pagemargin {bottomleft} bottomleft .pagemargin {bottomleftcorner} bottomleftcorner .pagemargin {leftbottom} leftbottom .pagemargin {leftmiddle} leftmiddle .pagemargin {lefttop} lefttop # Page 1 # Page 2 # Page 3 ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/format-page-numbers/format-page-numbers.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getMarginContent, getPageContainers} from "../index"; const {testMatrix, expect} = suite(__dirname); const EXPECTED_TEXTS = [ "1", "ii", "xx" ]; testMatrix( "renders formatted page numbers correctly", ["paged", "slides", "slides-print"], async (page, docType) => { const containers = getPageContainers(page, docType); await expect(containers).toHaveCount(3); for (let i = 0; i < 3; i++) { const container = containers.nth(i); const marginContent = getMarginContent(container, docType, "bottomcenter"); await expect(marginContent).toBeAttached(); await expect(marginContent).toHaveText(EXPECTED_TEXTS[i]); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/format-page-numbers/main.qd ================================================ .pagemargin {bottomcenter} .currentpage # Page 1 # Page 2 .formatpagenumber format:{i} # Page 20 .resetpagenumber start:{20} ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/index.ts ================================================ import {Locator, Page} from "@playwright/test"; import { MARGIN_TARGETS as DOCS_MARGIN_CONTAINERS } from "../../../main/typescript/document/handlers/page-margins/page-margins-docs"; // Margin names export const MARGIN_NAMES = [ "topleftcorner", "topleft", "topcenter", "topright", "toprightcorner", "righttop", "rightmiddle", "rightbottom", "bottomrightcorner", "bottomright", "bottomcenter", "bottomleft", "bottomleftcorner", "leftbottom", "leftmiddle", "lefttop", ]; // Maps margin name to hyphenated suffix export const MARGIN_SUFFIX: Record<string, string> = { topleftcorner: "top-left-corner", topleft: "top-left", topcenter: "top-center", topright: "top-right", toprightcorner: "top-right-corner", righttop: "right-top", rightmiddle: "right-middle", rightbottom: "right-bottom", bottomrightcorner: "bottom-right-corner", bottomright: "bottom-right", bottomcenter: "bottom-center", bottomleft: "bottom-left", bottomleftcorner: "bottom-left-corner", leftbottom: "left-bottom", leftmiddle: "left-middle", lefttop: "left-top", }; // Maps hyphenated margin name to docs container selector export const DOCS_CONTAINERS: Record<string, string> = DOCS_MARGIN_CONTAINERS; /** * Gets the page/slide containers based on doctype. */ export function getPageContainers(page: Page, docType: string): Locator { return docType === "paged" ? page.locator(".pagedjs_page") : page.locator(".reveal .slide-background"); } /** * Gets the margin content locator for a specific margin on a container. */ export function getMarginContent(container: Locator, docType: string, marginName: string): Locator { const suffix = MARGIN_SUFFIX[marginName]; if (docType === "paged") { return container.locator(`.pagedjs_margin-${suffix} > .pagedjs_margin-content`); } return container.locator(`.page-margin-${suffix}.page-margin-content`); } ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/mirror/main.qd ================================================ .pagemargin {topoutsidecorner} topoutsidecorner .pagemargin {topoutside} topoutside .pagemargin {topinsidecorner} topinsidecorner .pagemargin {topinside} topinside .pagemargin {bottomoutsidecorner} bottomoutsidecorner .pagemargin {bottomoutside} bottomoutside .pagemargin {bottominsidecorner} bottominsidecorner .pagemargin {bottominside} bottominside # Page 1 # Page 2 # Page 3 # Page 4 ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/mirror/mirror.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getMarginContent, getPageContainers} from "../index"; const {testMatrix, expect} = suite(__dirname); // Mirror margins: inside/outside swap based on odd/even pages // Odd pages (1, 3): inside = left, outside = right // Even pages (2, 4): inside = right, outside = left const MIRROR_MARGINS: Record<string, {odd: string; even: string}> = { topoutsidecorner: {odd: "toprightcorner", even: "topleftcorner"}, topoutside: {odd: "topright", even: "topleft"}, topinsidecorner: {odd: "topleftcorner", even: "toprightcorner"}, topinside: {odd: "topleft", even: "topright"}, bottomoutsidecorner: {odd: "bottomrightcorner", even: "bottomleftcorner"}, bottomoutside: {odd: "bottomright", even: "bottomleft"}, bottominsidecorner: {odd: "bottomleftcorner", even: "bottomrightcorner"}, bottominside: {odd: "bottomleft", even: "bottomright"}, }; testMatrix( "renders mirrored page margins correctly", ["paged", "slides", "slides-print"], async (page, docType) => { const containers = getPageContainers(page, docType); await expect(containers).toHaveCount(4); for (const [mirrorName, positions] of Object.entries(MIRROR_MARGINS)) { for (let i = 0; i < 4; i++) { const container = containers.nth(i); const isOdd = (i + 1) % 2 === 1; const resolvedPosition = isOdd ? positions.odd : positions.even; const marginContent = getMarginContent(container, docType, resolvedPosition); await expect(marginContent).toBeAttached(); await expect(marginContent).toHaveText(mirrorName); } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/page-counter/main.qd ================================================ .pagemargin {bottomcenter} .currentpage / .totalpages # Page 1 # Page 2 # Page 3 # Page 20 .resetpagenumber start:{20} ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/page-counter/page-counter.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getMarginContent, getPageContainers} from "../index"; const {testMatrix, expect} = suite(__dirname); const EXPECTED_TEXTS = [ "1 / 4", "2 / 4", "3 / 4", "20 / 4" ]; testMatrix( "renders page counter correctly", ["paged", "slides", "slides-print"], async (page, docType) => { const containers = getPageContainers(page, docType); await expect(containers).toHaveCount(4); for (let i = 0; i < 4; i++) { const container = containers.nth(i); const marginContent = getMarginContent(container, docType, "bottomcenter"); await expect(marginContent).toBeAttached(); await expect(marginContent).toHaveText(EXPECTED_TEXTS[i]); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/persistent-headings/main.qd ================================================ .pagemargin {topleft} .lastheading {1} .pagemargin {topcenter} .lastheading {2} .pagemargin {topright} .lastheading {3} .autopagebreak maxdepth:{3} # 1 <<< abc ## 1.1 <<< def ### 1.1.1 <<< ghi ## 1.2 ### 1.2.1 # 2 ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/persistent-headings/persistent-headings.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getMarginContent, getPageContainers} from "../index"; const {testMatrix, expect} = suite(__dirname); // Expected content per page: [topleft, topcenter, topright] const EXPECTED_HEADINGS: [string, string, string][] = [ ["1", "", ""], ["1", "", ""], ["1", "1.1", ""], ["1", "1.1", ""], ["1", "1.1", "1.1.1"], ["1", "1.1", "1.1.1"], ["1", "1.2", ""], ["1", "1.2", "1.2.1"], ["2", "", ""], ]; const MARGINS = ["topleft", "topcenter", "topright"] as const; testMatrix( "displays persistent headings in page margins", ["paged", "slides", "slides-print"], async (page, docType) => { const pages = getPageContainers(page, docType); await expect(pages).toHaveCount(EXPECTED_HEADINGS.length); for (let i = 0; i < EXPECTED_HEADINGS.length; i++) { const pageContainer = pages.nth(i); const expected = EXPECTED_HEADINGS[i]; for (let j = 0; j < MARGINS.length; j++) { const marginContent = getMarginContent(pageContainer, docType, MARGINS[j]); const expectedText = expected[j]; if (expectedText) { await expect(marginContent).toHaveText(expectedText); } else { await expect(marginContent).toBeEmpty(); } } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/scoped/main.qd ================================================ .pagemargin {bottomcenter} A # Page 1 # Page 2 .pagemargin {topcenter} B # Page 3 .pagemargin {bottomcenter} C ================================================ FILE: quarkdown-html/src/test/e2e/page-margins/scoped/scoped.spec.ts ================================================ import {suite} from "../../quarkdown"; import {getMarginContent, getPageContainers} from "../index"; const {testMatrix, expect} = suite(__dirname); // Expected margins per page const EXPECTED: Array<{topcenter?: string; bottomcenter?: string}> = [ {bottomcenter: "A"}, {topcenter: "B", bottomcenter: "A"}, {topcenter: "B", bottomcenter: "C"}, ]; testMatrix( "renders scoped page margins correctly", ["paged", "slides", "slides-print"], async (page, docType) => { const containers = getPageContainers(page, docType); await expect(containers).toHaveCount(3); for (let i = 0; i < 3; i++) { const container = containers.nth(i); const expected = EXPECTED[i]; if (expected.topcenter) { const marginContent = getMarginContent(container, docType, "topcenter"); await expect(marginContent).toBeAttached(); await expect(marginContent).toHaveText(expected.topcenter); } if (expected.bottomcenter) { const marginContent = getMarginContent(container, docType, "bottomcenter"); await expect(marginContent).toBeAttached(); await expect(marginContent).toHaveText(expected.bottomcenter); } } } ); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/customization/customization.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies custom paragraph styling", async (page) => { const em = await getComputedSizeProperty(page, "1em"); const paragraphs = page.locator("p"); await expect(paragraphs).toHaveCount(2); const p1Style = await evaluateComputedStyle(paragraphs.nth(0)); const p2Style = await evaluateComputedStyle(paragraphs.nth(1)); expect(parseFloat(p1Style.lineHeight)).toBeCloseTo(3 * em, 1); expect(parseFloat(p1Style.letterSpacing)).toBeCloseTo(2 * em, 1); expect(p1Style.textIndent).toBe("0px"); // first paragraph has no indent by design expect(parseFloat(p2Style.textIndent)).toBeCloseTo(2 * em, 1); expect(p1Style.marginTop).toBe("0px"); expect(p2Style.marginTop).toBe("0px"); }); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/customization/main.qd ================================================ .paragraphstyle lineheight:{3} letterspacing:{2} spacing:{0} indent:{2} .loremipsum .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/localized-configuration/localized-configuration.spec.ts ================================================ import {evaluateComputedStyle} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies localized configuration (Chinese)", async (page) => { const paragraphs = page.locator("p"); await expect(paragraphs).toHaveCount(2); for (const i of [0, 1]) { const style = await evaluateComputedStyle(paragraphs.nth(i)); expect(style.lineHeight).not.toBe("0px"); expect(style.textIndent).not.toBe("0px"); expect(style.marginTop).toBe("0px"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/localized-configuration/main.qd ================================================ .doclang {zh} .loremipsum .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/base/base.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../../../__util/css"; import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); test("applies correct paragraph spacing", async (page) => { const paragraphMargin = await getComputedSizeProperty(page, "var(--qd-paragraph-vertical-margin)"); const paragraphs = page.locator("p"); await expect(paragraphs).toHaveCount(3); // First paragraph has no margins, // Second and third paragraphs have only margin-top for (const i of [0, 1, 2]) { const style = await evaluateComputedStyle(paragraphs.nth(i)); if (i == 0) { expect(style.marginTop).toBe("0px"); } else { expect(parseFloat(style.marginTop)).toBeCloseTo(paragraphMargin, 1); } expect(style.marginBottom).toBe("0px"); expect(style.marginLeft).toBe("0px"); expect(style.marginRight).toBe("0px"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/base/main.qd ================================================ A .loremipsum C ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/with-container/main.qd ================================================ .loremipsum .container .loremipsum .container .loremipsum .loremipsum .container .loremipsum .loremipsum .container .loremipsum .container | A | B | C | | - | - | - | | D | E | F | .container .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/with-container/with-container.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../../../__util/css"; import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); test("applies correct spacing with containers", async (page) => { const paragraphMargin = await getComputedSizeProperty(page, "var(--qd-paragraph-vertical-margin)"); const topParagraphs = page.locator("main > p"); const containers = page.locator("main > .container"); await expect(topParagraphs).toHaveCount(2); await expect(containers).toHaveCount(6); // Paragraph 1 has no margins (first element) const p1Style = await evaluateComputedStyle(topParagraphs.nth(0)); expect(p1Style.margin).toBe("0px"); // Paragraph 2 has only margin-top (preceded by container with p:last-child) const p2Style = await evaluateComputedStyle(topParagraphs.nth(1)); expect(parseFloat(p2Style.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(p2Style.marginBottom).toBe("0px"); // Containers 1, 2, 4 have margin-top, but their inner paragraphs have no margins for (const i of [0, 1, 3]) { const cStyle = await evaluateComputedStyle(containers.nth(i)); expect(parseFloat(cStyle.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(cStyle.marginBottom).toBe("0px"); const innerPStyle = await evaluateComputedStyle(containers.nth(i).locator("p").first()); expect(innerPStyle.margin).toBe("0px"); } // Container 3 has margin-top; first inner paragraph has no margins, second has margin-top const c3Style = await evaluateComputedStyle(containers.nth(2)); expect(parseFloat(c3Style.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(c3Style.marginBottom).toBe("0px"); const c3Paragraphs = containers.nth(2).locator("p"); await expect(c3Paragraphs).toHaveCount(2); const c3P1Style = await evaluateComputedStyle(c3Paragraphs.nth(0)); expect(c3P1Style.margin).toBe("0px"); const c3P2Style = await evaluateComputedStyle(c3Paragraphs.nth(1)); expect(parseFloat(c3P2Style.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(c3P2Style.marginBottom).toBe("0px"); // Container 5 (table) has no margins, and neither does its table const c5Style = await evaluateComputedStyle(containers.nth(4)); expect(c5Style.marginTop).toBe("0px"); expect(c5Style.marginBottom).toBe("0px"); const c5TableStyle = await evaluateComputedStyle(containers.nth(4).locator("table")); expect(c5TableStyle.marginTop).toBe("0px"); // Container 6 has no margins, and neither does its inner paragraph const c6Style = await evaluateComputedStyle(containers.nth(5)); expect(c6Style.marginTop).toBe("0px"); expect(c6Style.marginBottom).toBe("0px"); const c6PStyle = await evaluateComputedStyle(containers.nth(5).locator("p")); expect(c6PStyle.margin).toBe("0px"); }); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/with-float/main.qd ================================================ .doclang {English} .loremipsum .float {start} !(50)[](../../../__assets/rect.png) .loremipsum .float {end} !(50)[](../../../__assets/rect.png) .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/spacing/with-float/with-float.spec.ts ================================================ import {evaluateComputedStyle, getComputedSizeProperty} from "../../../__util/css"; import {suite} from "../../../quarkdown"; const {test, expect} = suite(__dirname); test("applies correct spacing with floating figure", async (page) => { const paragraphMargin = await getComputedSizeProperty(page, "var(--qd-paragraph-vertical-margin)"); const paragraphs = page.locator("p"); const floats = page.locator(".float"); await expect(paragraphs).toHaveCount(3); await expect(floats).toHaveCount(2); // First paragraph has no margins const p1Style = await evaluateComputedStyle(paragraphs.nth(0)); expect(p1Style.margin).toBe("0px"); // Second and third paragraphs have only margin-top for (const i of [0, 1]) { const pStyle = await evaluateComputedStyle(paragraphs.nth(i + 1)); expect(parseFloat(pStyle.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(pStyle.marginBottom).toBe("0px"); expect(pStyle.marginLeft).toBe("0px"); expect(pStyle.marginRight).toBe("0px"); const float = floats.nth(i); await expect(float).toBeAttached(); const floatStyle = await evaluateComputedStyle(float); expect(floatStyle.float).toBe(i == 0 ? "inline-start" : "inline-end"); expect(parseFloat(floatStyle.marginTop)).toBeCloseTo(paragraphMargin, 1); expect(floatStyle.marginBottom).toBe("0px"); if (i == 0) { expect(floatStyle.marginLeft).toBe("0px"); expect(floatStyle.marginRight).not.toBe("0px"); } else { expect(floatStyle.marginLeft).not.toBe("0px"); expect(floatStyle.marginRight).toBe("0px"); } const figure = float.locator("figure"); await expect(figure).toBeAttached(); const figureStyle = await evaluateComputedStyle(figure); expect(figureStyle.margin).toBe("0px"); // Floating element has same Y as its paragraph const pBox = await paragraphs.nth(i + 1).boundingBox(); const floatBox = await float.boundingBox(); expect(pBox).not.toBeNull(); expect(floatBox).not.toBeNull(); expect(floatBox!.y).toBeCloseTo(pBox!.y, 0); } }); ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/typography/main.qd ================================================ .loremipsum ================================================ FILE: quarkdown-html/src/test/e2e/paragraph/typography/typography.spec.ts ================================================ import {evaluateComputedStyle} from "../../__util/css"; import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies correct default paragraph typography", async (page) => { const paragraph = page.locator("p").first(); await expect(paragraph).toBeAttached(); const style = await evaluateComputedStyle(paragraph); // --qd-line-height is unitless (e.g., 1.5), so compare the ratio const lineHeightRatio = parseFloat(style.lineHeight) / parseFloat(style.fontSize); const expectedRatio = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--qd-line-height")) ); expect(lineHeightRatio).toBeCloseTo(expectedRatio, 1); expect(style.letterSpacing).toBe("normal"); expect(style.textIndent).toBe("0px"); }); ================================================ FILE: quarkdown-html/src/test/e2e/quarkdown.ts ================================================ import {expect, Page, test as base} from "@playwright/test"; import {DocumentType, OUTPUT_DIR} from "./__util/paths"; import {runTest} from "./__util/runner"; import * as path from "path"; export type {DocumentType} from "./__util/paths"; export interface TestOptions { /** Subdocument path to navigate to (e.g., "page2" for /output/page2/) */ subpath?: string; } /** * Returns the output directory path for a given test directory. */ export function outputDir(testDir: string): string { const e2eDir = path.resolve(__dirname); const outName = path.relative(e2eDir, testDir).split(path.sep).join("-"); return path.join(OUTPUT_DIR, outName); } /** * Creates a test suite for a specific directory. * @param testDir - The test directory (__dirname from the spec file) * @returns Suite object with test utilities */ export function suite(testDir: string) { return { /** * Defines a single test case. * @param name - Test name * @param fn - Test function receiving the Playwright page * @param options - Optional test configuration (subpath) */ test: (name: string, fn: (page: Page) => Promise<void>, options?: TestOptions) => { base(name, async ({page}) => runTest(testDir, page, fn, options)); }, /** * Runs the same test across multiple document types. * Creates separate test cases for each document type. * @param name - Test name (document type will be appended in brackets) * @param docTypes - Array of document types to test against * @param fn - Test function receiving page and current document type * @param options - Optional test configuration (subpath) */ testMatrix: ( name: string, docTypes: DocumentType[], fn: (page: Page, docType: DocumentType) => Promise<void>, options?: TestOptions ) => { for (const docType of docTypes) { base(`${name} [${docType}]`, async ({page}) => runTest(testDir, page, (p) => fn(p, docType), {docType, ...options}) ); } }, /** Playwright expect function for assertions */ expect, }; } ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/empty/empty.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("sidebar contains only an empty ol", async (page) => { const sidebar = page.locator(".sidebar"); await expect(sidebar).toBeAttached(); // Sidebar should have exactly one direct child: an ol const children = sidebar.locator("> *"); await expect(children).toHaveCount(1); const ol = sidebar.locator("> ol"); await expect(ol).toBeAttached(); // The ol should have no items await expect(ol.locator("li")).toHaveCount(0); }); ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/empty/main.qd ================================================ Hello ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/issue_border-in-minimal-theme/border-in-minimal-theme.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("sidebar and children have no border in minimal theme", async (page) => { const sidebar = page.locator(".sidebar"); await expect(sidebar).toBeAttached(); // Collect sidebar and all nested children const allElements = sidebar.locator("*"); const count = await allElements.count(); // Sidebar itself should have no border await expect(sidebar).toHaveCSS("border-top-style", "none"); await expect(sidebar).toHaveCSS("border-right-style", "none"); await expect(sidebar).toHaveCSS("border-bottom-style", "none"); await expect(sidebar).toHaveCSS("border-left-style", "none"); // All children should have no border for (let i = 0; i < count; i++) { const el = allElements.nth(i); await expect(el).toHaveCSS("border-top-style", "none"); await expect(el).toHaveCSS("border-right-style", "none"); await expect(el).toHaveCSS("border-bottom-style", "none"); await expect(el).toHaveCSS("border-left-style", "none"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/issue_border-in-minimal-theme/bulletin.yml ================================================ - pr: 387 - next-release: 1.15.0 - cause: "Minimal theme applies border to `nav ol ol` elements. Fixed by `border: 0` in _sidebar.scss" ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/issue_border-in-minimal-theme/main.qd ================================================ .theme layout:{minimal} # A ## B ### C ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/main.qd ================================================ # A # B ## C ### D #### Skipped ## E # F ##! Skipped ## G ================================================ FILE: quarkdown-html/src/test/e2e/sidebar/sidebar.spec.ts ================================================ import {getComputedColor} from "../__util/css"; import {suite} from "../quarkdown"; const {testMatrix, expect} = suite(__dirname); // sm breakpoint is 800px const MOBILE_WIDTH = 600; testMatrix( "renders sidebar correctly per doctype", ["plain", "paged", "slides", "slides-print", "docs"], async (page, docType) => { const sidebar = page.locator(".sidebar"); if (docType === "slides" || docType === "slides-print" || docType === "docs") { // Slides and docs don't have sidebar await expect(sidebar).not.toBeAttached(); return; } // Plain and paged have sidebar await expect(sidebar).toBeAttached(); // Should have 7 items: A, B, C, D, E, F, G // Skipped: h4 (#### Skipped) and decorative (##! Skipped) const items = sidebar.locator("li"); await expect(items).toHaveCount(7); // Verify no "Skipped" text appears in sidebar const sidebarText = await sidebar.textContent(); expect(sidebarText).not.toContain("Skipped"); // Check dash widths hierarchy: h1 > h2 > h3 const h1Dash = sidebar.locator('li[data-depth="1"] > a').first(); const h2Dash = sidebar.locator('li[data-depth="2"] > a').first(); const h3Dash = sidebar.locator('li[data-depth="3"] > a').first(); const h1Width = await h1Dash.evaluate((el) => { const after = getComputedStyle(el, "::after"); return parseFloat(after.width); }); const h2Width = await h2Dash.evaluate((el) => { const after = getComputedStyle(el, "::after"); return parseFloat(after.width); }); const h3Width = await h3Dash.evaluate((el) => { const after = getComputedStyle(el, "::after"); return parseFloat(after.width); }); expect(h1Width).toBeGreaterThan(h2Width); expect(h2Width).toBeGreaterThan(h3Width); // Check colors based on doctype const dashColor = await h1Dash.evaluate((el) => { return getComputedStyle(el, "::after").backgroundColor; }); const mainColor = await getComputedColor(page, "var(--qd-main-color)"); if (docType === "paged") { expect(dashColor).not.toBe(mainColor); } else { expect(dashColor).toBe(mainColor); } } ); testMatrix( "hides sidebar on mobile in plain doctype", ["plain"], async (page) => { await page.setViewportSize({width: MOBILE_WIDTH, height: 800}); const sidebar = page.locator(".sidebar"); await expect(sidebar).toBeAttached(); await expect(sidebar).toHaveCSS("display", "none"); } ); testMatrix( "hides sidebar in print mode", ["plain", "paged"], async (page) => { const sidebar = page.locator(".sidebar"); await expect(sidebar).toBeAttached(); await page.emulateMedia({media: "print"}); await expect(sidebar).toHaveCSS("display", "none"); } ); testMatrix( "highlights currently viewed heading", ["paged"], async (page) => { // Setting viewport size to height of a single page const pageBox = await page.locator(".pagedjs_page").first().boundingBox(); expect(pageBox).not.toBeNull(); await page.setViewportSize({width: Math.floor(pageBox!.width), height: Math.floor(pageBox!.height)}); const sidebar = page.locator(".sidebar"); const items = sidebar.locator("li"); const activeItems = sidebar.locator("li.active"); // Only first item should be highlighted initially await expect(activeItems).toHaveCount(1); await expect(items.first()).toHaveClass(/active/); // Scroll to end of document await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); // Only second to last item should be highlighted await expect(activeItems).toHaveCount(1); await expect(items.nth(5)).toHaveClass(/active/); } ); ================================================ FILE: quarkdown-html/src/test/e2e/slides/chunking/chunking.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("chunks slides correctly", async (page) => { const slides = page.locator(".reveal .slides > section"); await expect(slides).toHaveCount(5); // Slide 1: # A, ## B await expect(slides.nth(0).locator("h1")).toHaveText("A"); await expect(slides.nth(0).locator("h2")).toHaveText("B"); // Slide 2: # C await expect(slides.nth(1).locator("h1")).toHaveText("C"); // Slide 3: # D await expect(slides.nth(2).locator("h1")).toHaveText("D"); // Slide 4: explicit page break (<<<), then "xyz" await expect(slides.nth(3).locator("p")).toHaveText("xyz"); // Slide 5: # E await expect(slides.nth(4).locator("h1")).toHaveText("E"); }); ================================================ FILE: quarkdown-html/src/test/e2e/slides/chunking/main.qd ================================================ .doctype {slides} # A ## B # C # D <<< xyz # E ================================================ FILE: quarkdown-html/src/test/e2e/stack/default/default.spec.ts ================================================ import {suite} from "../../quarkdown"; import {BoundingBox, getChildBoxes} from "../index"; const {test, expect} = suite(__dirname); function assertHorizontalRow(boxes: BoundingBox[]) { for (let i = 1; i < boxes.length; i++) { // Same Y expect(boxes[i].y).toBeCloseTo(boxes[0].y, 0); // Starts where previous ends expect(boxes[i - 1].x + boxes[i - 1].width).toBeCloseTo(boxes[i].x, 0); } } function assertVerticalColumn(boxes: BoundingBox[]) { for (let i = 1; i < boxes.length; i++) { // Same X expect(boxes[i].x).toBeCloseTo(boxes[0].x, -1); // Starts where previous ends expect(boxes[i - 1].y + boxes[i - 1].height).toBeCloseTo(boxes[i].y, 0); } } test("row positions children horizontally with 0px gap", async (page) => { const boxes = await getChildBoxes(page.locator(".stack.stack-row"), 3); assertHorizontalRow(boxes); }); test("column positions children vertically with 0px gap", async (page) => { const boxes = await getChildBoxes(page.locator(".stack.stack-column"), 3); assertVerticalColumn(boxes); }); test("grid positions children in 2-column layout", async (page) => { const boxes = await getChildBoxes(page.locator(".stack.stack-grid"), 3); // First row: A, B assertHorizontalRow([boxes[0], boxes[1]]); // Second row: C (below, aligned with A) assertVerticalColumn([boxes[0], boxes[2]]); }); test("stack elements have correct margins", async (page) => { const stacks = page.locator(".stack"); for (const stack of await stacks.all()) { const marginTop = await stack.evaluate((el) => parseFloat(getComputedStyle(el).marginTop)); const marginBottom = await stack.evaluate((el) => parseFloat(getComputedStyle(el).marginBottom)); expect(marginTop).toBeGreaterThan(0); expect(marginBottom).toBeGreaterThan(0); expect(marginTop).toBeCloseTo(marginBottom, 0); } }); test("paragraphs inside stacks have 0 margin", async (page) => { const paragraphs = page.locator(".stack > p"); for (const p of await paragraphs.all()) { await expect(p).toHaveCSS("margin", "0px"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/stack/default/main.qd ================================================ .row A B C .column A B C .grid columns:{2} A B C ================================================ FILE: quarkdown-html/src/test/e2e/stack/gap/gap.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertHorizontalRow, assertVerticalColumn, getChildBoxes} from "../index"; const {test, expect} = suite(__dirname); test("row applies horizontal gap between children", async (page) => { const expectedGap = 10; const row = page.locator(".stack.stack-row"); const boxes = await getChildBoxes(row, 3); await expect(row).toHaveCSS("column-gap", `${expectedGap}px`); assertHorizontalRow(boxes, expectedGap); }); test("column applies vertical gap between children", async (page) => { const expectedGap = 15; const column = page.locator(".stack.stack-column"); const boxes = await getChildBoxes(column, 3); await expect(column).toHaveCSS("row-gap", `${expectedGap}px`); assertVerticalColumn(boxes, expectedGap); }); test("grid applies gap between children in both directions", async (page) => { const expectedGap = 20; const grid = page.locator(".stack.stack-grid"); const boxes = await getChildBoxes(grid, 3); await expect(grid).toHaveCSS("gap", `${expectedGap}px`); await expect(grid).toHaveCSS("row-gap", `${expectedGap}px`); await expect(grid).toHaveCSS("column-gap", `${expectedGap}px`); // First row: A, B assertHorizontalRow([boxes[0], boxes[1]], expectedGap); // Second row: C (below, aligned with A) assertVerticalColumn([boxes[0], boxes[2]], expectedGap); }); ================================================ FILE: quarkdown-html/src/test/e2e/stack/gap/main.qd ================================================ .row gap:{10px} A B C .column gap:{15px} A B C .grid columns:{2} gap:{20px} A B C ================================================ FILE: quarkdown-html/src/test/e2e/stack/grid-row-column-gap/grid-row-column-gap.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertHorizontalRow, assertVerticalColumn, getChildBoxes} from "../index"; const {test, expect} = suite(__dirname); const EXPECTED_GAPS = [ ["grid applies row (vertical) gap", 10, undefined], ["grid applies column (horizontal) gap", undefined, 15], ["grid applies row gap and column gap", 20, 25], ["grid row gap defaults to gap", 30, 35], ["grid column gap defaults to gap", 45, 40], ["the order of row|column-gap and gap is irrelevant", 50, 55] ]; for (const {index, testName, expectedRowGap, expectedColumnGap} of EXPECTED_GAPS .map(([testName, expectedRowGap, expectedColumnGap], index) => ({ index, testName: testName as string, expectedRowGap: expectedRowGap as number | undefined, expectedColumnGap: expectedColumnGap as number | undefined }))) { test(testName, async (page) => { const grid = page.locator(".stack.stack-grid").nth(index); const boxes = await getChildBoxes(grid, 3); if (expectedRowGap !== undefined) { await expect(grid).toHaveCSS("row-gap", `${expectedRowGap}px`); } if (expectedColumnGap !== undefined) { await expect(grid).toHaveCSS("column-gap", `${expectedColumnGap}px`); } // First row: A, B assertHorizontalRow([boxes[0], boxes[1]], expectedColumnGap || 0); // Second row: C (below, aligned with A) assertVerticalColumn([boxes[0], boxes[2]], expectedRowGap || 0); }); } ================================================ FILE: quarkdown-html/src/test/e2e/stack/grid-row-column-gap/main.qd ================================================ .grid columns:{2} vgap:{10px} A B C .grid columns:{2} hgap:{15px} A B C .grid columns:{2} vgap:{20px} hgap:{25px} A B C .grid columns:{2} gap:{30px} hgap:{35px} A B C .grid columns:{2} gap:{40px} vgap:{45px} A B C .grid columns:{2} vgap:{50px} gap:{55px} A B C ================================================ FILE: quarkdown-html/src/test/e2e/stack/index.ts ================================================ import {expect as playwrightExpect, Locator} from "@playwright/test"; import {suite} from "../quarkdown"; export type BoundingBox = NonNullable<Awaited<ReturnType<Locator["boundingBox"]>>>; const {expect} = suite(__dirname); export async function getChildBoxes(container: Locator, count: number): Promise<BoundingBox[]> { const children = container.locator("> p"); await playwrightExpect(children).toHaveCount(count); const boxes = await Promise.all( Array.from({length: count}, (_, i) => children.nth(i).boundingBox()) ); return boxes as BoundingBox[]; } export function assertHorizontalRow(boxes: BoundingBox[], gap: number) { for (let i = 1; i < boxes.length; i++) { const prevBoxRight = boxes[i - 1].x + boxes[i - 1].width; expect(boxes[i].x - prevBoxRight).toBeCloseTo(gap, 0); } } export function assertVerticalColumn(boxes: BoundingBox[], gap: number) { for (let i = 1; i < boxes.length; i++) { const prevBoxBottom = boxes[i - 1].y + boxes[i - 1].height; expect(boxes[i].y - prevBoxBottom).toBeCloseTo(gap, 0); } } ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/case/case.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies text case transforms", async (page) => { await expect(page.locator("text=LOWERCASE")).toHaveCSS("text-transform", "lowercase"); await expect(page.locator("text=uppercase")).toHaveCSS("text-transform", "uppercase"); await expect(page.locator("text=capitalize this")).toHaveCSS("text-transform", "capitalize"); }); ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/case/main.qd ================================================ .text {LOWERCASE} case:{lowercase} .text {uppercase} case:{uppercase} .text {capitalize this} case:{capitalize} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/decoration/decoration.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies text decoration styles", async (page) => { await expect(page.getByText("underline", {exact: true})).toHaveCSS("text-decoration-line", "underline"); await expect(page.getByText("overline", {exact: true})).toHaveCSS("text-decoration-line", "overline"); await expect(page.getByText("underoverline", {exact: true})).toHaveCSS("text-decoration-line", "underline overline"); await expect(page.getByText("strikethrough", {exact: true})).toHaveCSS("text-decoration-line", "line-through"); await expect(page.getByText("all", {exact: true})).toHaveCSS("text-decoration-line", "underline overline line-through"); }); ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/decoration/main.qd ================================================ .text {underline} decoration:{underline} .text {overline} decoration:{overline} .text {underoverline} decoration:{underoverline} .text {strikethrough} decoration:{strikethrough} .text {all} decoration:{all} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/size/main.qd ================================================ .text {tiny} size:{tiny} .text {small} size:{small} .text {normal} size:{normal} .text {medium} size:{medium} .text {large} size:{large} .text {larger} size:{larger} .text {huge} size:{huge} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/size/size.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies size classes with increasing font sizes", async (page) => { const sizes = ["tiny", "small", "normal", "medium", "large", "larger", "huge"]; let prevSize = 0; for (const size of sizes) { const fontSize = await page.locator(`.size-${size}`).evaluate((e) => parseFloat(getComputedStyle(e).fontSize)); expect(fontSize).toBeGreaterThan(prevSize); prevSize = fontSize; } }); ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/style/main.qd ================================================ .text {normal} style:{normal} .text {italic} style:{italic} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/style/style.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies font style", async (page) => { await expect(page.locator("text=normal")).toHaveCSS("font-style", "normal"); await expect(page.locator("text=italic")).toHaveCSS("font-style", "italic"); }); ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/variant/main.qd ================================================ .text {small caps} variant:{smallcaps} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/variant/variant.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies font variant", async (page) => { await expect(page.locator("text=small caps")).toHaveCSS("font-variant", /small-caps/); }); ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/weight/main.qd ================================================ .text {normal} weight:{normal} .text {bold} weight:{bold} ================================================ FILE: quarkdown-html/src/test/e2e/text-formatting/weight/weight.spec.ts ================================================ import {suite} from "../../quarkdown"; const {test, expect} = suite(__dirname); test("applies font weight", async (page) => { await expect(page.locator("text=normal")).toHaveCSS("font-weight", "400"); await expect(page.locator("text=bold")).toHaveCSS("font-weight", "700"); }); ================================================ FILE: quarkdown-html/src/test/e2e/toc/format-page-numbers/format-page-numbers.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertTocStructure} from "../index"; const {testMatrix, expect} = suite(__dirname); const EXPECTED_TEXTS = [ "2", "3", "xx", "xxi" ]; testMatrix( "renders table of contents with formatted page numbers correctly", ["plain", "paged", "slides", "slides-print", "docs"], async (page, docType) => { const count = 4; const {nav, items} = await assertTocStructure(page, count); const pageNumbers = nav.locator(".toc-page-number"); if (docType === "paged" || docType === "slides" || docType === "slides-print") { await expect(pageNumbers).toHaveCount(count); // Check page number values for (let i = 0; i < count; i++) { await expect(pageNumbers.nth(i)).toHaveText(EXPECTED_TEXTS[i]); } // Check positioning: page numbers should be in the last fourth of the item const itemBox = await items.first().boundingBox(); const pageNumBox = await pageNumbers.first().boundingBox(); expect(itemBox).not.toBeNull(); expect(pageNumBox).not.toBeNull(); const itemRight = itemBox!.x + itemBox!.width; const lastFourthStart = itemBox!.x + itemBox!.width * 0.75; expect(pageNumBox!.x).toBeGreaterThanOrEqual(lastFourthStart); expect(pageNumBox!.x + pageNumBox!.width).toBeLessThanOrEqual(itemRight + 1); } else { // Plain and docs don't have page numbers await expect(pageNumbers).toHaveCount(0); } } ); ================================================ FILE: quarkdown-html/src/test/e2e/toc/format-page-numbers/main.qd ================================================ .tableofcontents # 1 # 2 # 3 .formatpagenumber format:{i} .resetpagenumber {20} # 4 ================================================ FILE: quarkdown-html/src/test/e2e/toc/index.ts ================================================ import {expect, Page} from "@playwright/test"; export const TOC_SELECTOR = "nav[data-role='table-of-contents']"; export const EXPECTED_NUMBERS = ["1", "2", "2.1", "2.1.1", "3", "3.1", "3.2"]; export async function assertTocStructure(page: Page, itemCount: number = 7) { const nav = page.locator(TOC_SELECTOR); await expect(nav).toBeAttached(); const items = nav.locator("li"); await expect(items).toHaveCount(itemCount); return {nav, items}; } export async function assertTocNumbering( page: Page, format: "latex" | "minimal", expectedNumbers: string[] = EXPECTED_NUMBERS ) { const nav = page.locator(TOC_SELECTOR); const items = nav.locator("li"); for (let i = 0; i < expectedNumbers.length; i++) { const content = await items.nth(i).evaluate((el) => { return getComputedStyle(el, "::before").content; }); // latex: '" 1 "', minimal: '" 1"' const expected = format === "latex" ? `" ${expectedNumbers[i]} "` : `" ${expectedNumbers[i]}"`; expect(content).toBe(expected); } } export async function assertTocLinks(page: Page, expectedTexts: string[] = EXPECTED_NUMBERS) { const nav = page.locator(TOC_SELECTOR); const links = nav.locator("a"); await expect(links).toHaveCount(expectedTexts.length); for (let i = 0; i < expectedTexts.length; i++) { const link = links.nth(i); await expect(link).toHaveText(expectedTexts[i]); await expect(link).toHaveAttribute("href", `#_${expectedTexts[i].replace(/\./g, "")}`); } } ================================================ FILE: quarkdown-html/src/test/e2e/toc/latex/latex.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertTocLinks, assertTocNumbering, assertTocStructure, TOC_SELECTOR} from "../index"; const {test, expect} = suite(__dirname); test("renders table of contents with correct structure", async (page) => { const {nav, items} = await assertTocStructure(page); // No borders on list elements const ol = nav.locator("ol"); await expect(ol.first()).toHaveCSS("border-style", "none"); await expect(items.first()).toHaveCSS("border-style", "none"); }); test("displays correct numbering in li::before", async (page) => { await assertTocNumbering(page, "latex"); }); test("links to correct heading anchors", async (page) => { await assertTocLinks(page); }); test("first-level links are bold, others are normal", async (page) => { const nav = page.locator(TOC_SELECTOR); const firstLevelLinks = nav.locator("> :is(ul, ol) > li > a"); const nestedLinks = nav.locator(":is(ul, ol) :is(ul, ol) a"); // First-level links are bold for (const link of await firstLevelLinks.all()) { await expect(link).toHaveCSS("font-weight", "700"); } // Nested links are normal for (const link of await nestedLinks.all()) { await expect(link).toHaveCSS("font-weight", "400"); } }); ================================================ FILE: quarkdown-html/src/test/e2e/toc/latex/main.qd ================================================ .theme layout:{latex} .numbering - headings: 1.1.1 .tableofcontents # 1 # 2 ## 2.1 ### 2.1.1 # 3 ## 3.1 ## 3.2 ================================================ FILE: quarkdown-html/src/test/e2e/toc/minimal/main.qd ================================================ .theme layout:{minimal} .numbering - headings: 1.1.1 .tableofcontents # 1 # 2 ## 2.1 ### 2.1.1 # 3 ## 3.1 ## 3.2 ================================================ FILE: quarkdown-html/src/test/e2e/toc/minimal/minimal.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertTocLinks, assertTocNumbering, assertTocStructure} from "../index"; const {test, expect} = suite(__dirname); test("renders table of contents with correct structure", async (page) => { const {nav} = await assertTocStructure(page); // Root list has no border const rootOl = nav.locator("> ol"); await expect(rootOl).toHaveCSS("border-left-style", "none"); // Nested lists have border-left const nestedOl = nav.locator("ol ol").first(); await expect(nestedOl).toHaveCSS("border-left-style", "solid"); await expect(nestedOl).toHaveCSS("border-left-width", "2px"); }); test("displays correct numbering in li::before", async (page) => { await assertTocNumbering(page, "minimal"); }); test("location markers have reduced opacity", async (page) => { const nav = page.locator("nav"); const item = nav.locator("li").first(); const opacity = await item.evaluate((el) => { return getComputedStyle(el, "::before").opacity; }); expect(parseFloat(opacity)).toBeLessThan(1); }); test("links to correct heading anchors", async (page) => { await assertTocLinks(page); }); ================================================ FILE: quarkdown-html/src/test/e2e/toc/page-numbers/main.qd ================================================ .tableofcontents # 1 # 2 # 3 .resetpagenumber {20} # 4 ================================================ FILE: quarkdown-html/src/test/e2e/toc/page-numbers/page-numbers.spec.ts ================================================ import {suite} from "../../quarkdown"; import {assertTocStructure} from "../index"; const {testMatrix, expect} = suite(__dirname); testMatrix( "renders table of contents with page numbers for paged doctypes", ["plain", "paged", "slides", "slides-print", "docs"], async (page, docType) => { const count = 4; const {nav, items} = await assertTocStructure(page, count); const pageNumbers = nav.locator(".toc-page-number"); if (docType === "paged" || docType === "slides" || docType === "slides-print") { await expect(pageNumbers).toHaveCount(count); // Check page number values for (let i = 0; i < count; i++) { const number = i + (i >= 2 ? 18 : 2); // First page is ToC, last two page are reset to 20 await expect(pageNumbers.nth(i)).toHaveText(`${number}`); } // Check positioning: page numbers should be in the last fourth of the item const itemBox = await items.first().boundingBox(); const pageNumBox = await pageNumbers.first().boundingBox(); expect(itemBox).not.toBeNull(); expect(pageNumBox).not.toBeNull(); const itemRight = itemBox!.x + itemBox!.width; const lastFourthStart = itemBox!.x + itemBox!.width * 0.75; expect(pageNumBox!.x).toBeGreaterThanOrEqual(lastFourthStart); expect(pageNumBox!.x + pageNumBox!.width).toBeLessThanOrEqual(itemRight + 1); } else { // Plain and docs don't have page numbers await expect(pageNumbers).toHaveCount(0); } } ); ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/HtmlIdentifiersTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.attributes.id.getId import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.rendering.html.node.QuarkdownHtmlNodeRenderer import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for generation of HTML ids via [HtmlIdentifierProvider]. */ class HtmlIdentifiersTest { private val provider = HtmlIdentifierProvider.of(QuarkdownHtmlNodeRenderer(MutableContext(QuarkdownFlavor))) private fun assertIdEquals( expected: String, headingText: String, ) { assertEquals( expected, provider.getId(Heading(1, listOf(Text(headingText)))), ) } @Test fun `with uppercase`() { assertIdEquals("abc", "Abc") } @Test fun `with spaces`() { assertIdEquals("abc-def", "Abc Def") } @Test fun `with tabs`() { assertIdEquals("abc-def", "Abc\tDef") } @Test fun `with special characters`() { assertIdEquals("hello-world", "Hello, World!") } @Test fun `with continuous special characters`() { assertIdEquals("hello-world", "Hello,,, World!!") } @Test fun `with numbers`() { assertIdEquals("abc-123", "Abc 123") } @Test fun `with leading numbers`() { assertIdEquals("_123abc", "123abc") } @Test fun `with accented letters`() { assertIdEquals("abc-déf", "Abc Déf") } @Test fun `with chinese characters`() { assertIdEquals("abc-你好", "Abc 你好") assertIdEquals("你好-abc", "你好 abc") } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/HtmlNodeRendererTest.kt ================================================ @file:Suppress("ktlint:standard:no-wildcard-imports") package com.quarkdown.rendering.html import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.attributes.reference.setDefinition import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.TaskListItemVariant import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.block.setIndex import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import com.quarkdown.core.ast.quarkdown.block.ImageFigure import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.ast.quarkdown.block.list.FocusListItemVariant import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.attachMockPipeline import com.quarkdown.core.bibliography.Bibliography import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.bibliography.style.BibliographyEntryLabelProviderStrategy import com.quarkdown.core.bibliography.style.BibliographyStyle import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.MutableContextOptions import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.document.size.cm import com.quarkdown.core.document.size.inch import com.quarkdown.core.document.size.percent import com.quarkdown.core.document.size.px import com.quarkdown.core.flavor.base.BaseMarkdownFlavor import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.misc.color.Color import com.quarkdown.core.misc.color.decoder.HexColorDecoder import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.Pipelines import com.quarkdown.core.readSource import com.quarkdown.core.rendering.NodeRenderer import com.quarkdown.core.util.node.toPlainText import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.rendering.html.extension.html import kotlin.test.Test import kotlin.test.assertEquals /** * HTML node rendering tests. */ class HtmlNodeRendererTest { private fun readParts(path: String) = readSource("/rendering/$path") .normalizeLineSeparators() .split("\n---\n") .map { it.trim() } .iterator() private fun renderer(context: Context = MutableContext(QuarkdownFlavor)): NodeRenderer { if (context.attachedPipeline == null) { // Attach a mock pipeline to the context, allowing to render pretty output // (since its value is retrieved from the attached pipeline) Pipelines.attach( context, MutableContext(context.flavor).attachMockPipeline(PipelineOptions(prettyOutput = true)), ) } return context.flavor.rendererFactory .html(context) .nodeRenderer } private fun Node.render(context: Context = MutableContext(QuarkdownFlavor)) = this.accept(renderer(context)) // Inline @Test fun comment() { assertEquals("", Comment.render()) } @Test fun lineBreak() { assertEquals("<br />", LineBreak.render()) } @Test fun criticalContent() { assertEquals("&amp;", CriticalContent("&").render()) assertEquals("&gt;", CriticalContent(">").render()) assertEquals("~", CriticalContent("~").render()) } @Test fun link() { val out = readParts("inline/link.html") assertEquals( out.next(), Link(label = listOf(Text("Foo bar")), url = "https://google.com", title = null).render(), ) assertEquals( out.next(), Link(label = listOf(Strong(listOf(Text("Foo bar")))), url = "/url", title = null).render(), ) assertEquals( out.next(), Link(label = listOf(Text("Foo bar baz")), url = "url", title = "Title").render(), ) } @Test fun referenceLink() { val out = readParts("inline/reflink.html") val label = listOf(Strong(listOf(Text("Foo")))) val context = MutableContext() val fallback = { Emphasis(listOf(Text("fallback"))) } // Resolved: label matches the reference, definition is set. val resolved = ReferenceLink(label, label, fallback).also { it.setDefinition(context, Link(it.label, "/url", "Title")) } assertEquals(out.next(), resolved.render(context)) // Resolved: different display label, same reference label. val resolvedDifferentLabel = ReferenceLink(listOf(Text("label")), label, fallback).also { it.setDefinition(context, Link(it.label, "/url", "Title")) } assertEquals(out.next(), resolvedDifferentLabel.render(context)) // Unresolved: fallback is rendered. assertEquals( out.next(), ReferenceLink(listOf(Text("label")), label, fallback).render(), ) } @Test fun image() { val out = readParts("inline/image.html") assertEquals( out.next(), Image( Link(label = listOf(), url = "/url", title = null), width = null, height = null, ).render(), ) assertEquals( out.next(), Image( Link(label = listOf(), url = "/url", title = "Title"), width = null, height = null, ).render(), ) assertEquals( out.next(), Image( Link(label = buildInline { text("Foo bar") }, url = "/url", title = null), width = 150.px, height = 100.px, ).render(), ) assertEquals( out.next(), Image( Link(label = buildInline { text("Foo bar") }, url = "/url", title = "Title"), width = 3.2.cm, height = null, ).render(), ) } @Test fun referenceImage() { val out = readParts("inline/refimage.html") val label = listOf(Text("Foo")) val context = MutableContext() val fallback = { Emphasis(listOf(Text("fallback"))) } fun resolvedRefLink(displayLabel: InlineContent) = ReferenceLink(displayLabel, label, fallback).also { it.setDefinition(context, Link(displayLabel, "/url", "Title")) } assertEquals( out.next(), ReferenceImage( resolvedRefLink(label), width = null, height = null, ).render(context), ) assertEquals( out.next(), ReferenceImage( resolvedRefLink(listOf(Text("label"))), width = null, height = null, ).render(context), ) assertEquals( out.next(), ReferenceImage( resolvedRefLink(listOf(Text("label"))), width = 150.px, height = 100.px, ).render(context), ) assertEquals( out.next(), ReferenceImage( ReferenceLink( listOf(Text("label")), label, fallback, ), width = null, height = null, ).render(), ) } @Test fun figure() { val out = readParts("quarkdown/figure.html") assertEquals( out.next(), ImageFigure( Image( Link(label = listOf(), url = "/url", title = ""), width = null, height = null, ), ).render(), ) assertEquals( out.next(), ImageFigure( Image( Link(label = listOf(), url = "/url", title = "Title"), width = null, height = null, ), ).render(), ) assertEquals( out.next(), ImageFigure( Image( Link(label = listOf(), url = "/url", title = "Title"), width = 150.px, height = 100.px, ), ).render(), ) } @Test fun footnoteDefinition() { val out = readParts("block/footnote.html") val context = MutableContext(QuarkdownFlavor) val definition = FootnoteDefinition( label = "label", text = buildInline { text("Foo bar") }, ) definition.setIndex(context, 0) assertEquals( out.next(), definition.render(context), ) } @Test fun footnoteReference() { val out = readParts("inline/reffootnote.html") val context = MutableContext(QuarkdownFlavor) val definition = FootnoteDefinition( label = "label", text = buildInline { text("Foo bar") }, ) definition.setIndex(context, 0) val reference = ReferenceFootnote( label = "label", fallback = { Text("fallback") }, ) reference.setDefinition(context, definition) assertEquals( out.next(), reference.render(context), ) assertEquals( out.next(), reference.render(), ) } @Test fun text() { assertEquals("Foo bar", Text("Foo bar").render()) assertEquals("&copy;", TextSymbol('©').render()) } @Test fun codeSpan() { val out = readParts("inline/codespan.html") // The Quarkdown rendering wraps the content in a span which allows additional content, such as color. val base = MutableContext(BaseMarkdownFlavor) val quarkdown = MutableContext(QuarkdownFlavor) val spanWithColor = CodeSpan( "#FFFF00", CodeSpan.ColorContent(HexColorDecoder.decode("#FFFF00")!!), ) assertEquals(out.next(), CodeSpan("Foo bar").render(base)) assertEquals(out.next(), CodeSpan("<a href=\"#\">").render(base)) assertEquals(out.next(), spanWithColor.render(quarkdown)) assertEquals(out.next(), spanWithColor.render(base)) assertEquals(out.next(), CodeSpan("Foo bar").render(quarkdown)) } @Test fun emphasis() { val out = readParts("inline/emphasis.html") assertEquals(out.next(), Emphasis(listOf(Text("Foo bar"))).render()) assertEquals(out.next(), Emphasis(listOf(Emphasis(listOf(Text("Foo bar"))))).render()) } @Test fun strong() { val out = readParts("inline/strong.html") assertEquals(out.next(), Strong(listOf(Text("Foo bar"))).render()) assertEquals(out.next(), Strong(listOf(Strong(listOf(Text("Foo bar"))))).render()) } @Test fun strongEmphasis() { val out = readParts("inline/strongemphasis.html") assertEquals(out.next(), StrongEmphasis(listOf(Text("Foo bar"))).render()) assertEquals(out.next(), StrongEmphasis(listOf(StrongEmphasis(listOf(Text("Foo bar"))))).render()) } @Test fun strikethrough() { val out = readParts("inline/strikethrough.html") assertEquals(out.next(), Strikethrough(listOf(Text("Foo bar"))).render()) assertEquals(out.next(), Strikethrough(listOf(Strong(listOf(Text("Foo bar"))))).render()) } @Test fun plainTextConversion() { val inline: InlineContent = listOf( Text("abc"), Strong( listOf( Emphasis( listOf( Text("def"), CodeSpan("ghi"), ), ), CodeSpan("jkl"), ), ), Text("mno"), CriticalContent("&"), ) assertEquals("abcdefghijklmno&", inline.toPlainText()) // Critical content is rendered differently assertEquals("abcdefghijklmno&amp;", inline.toPlainText(renderer())) } // Block @Test fun code() { val out = readParts("block/code.html") assertEquals(out.next(), Code("Code", language = null, showLineNumbers = true).render()) assertEquals(out.next(), Code("Code", language = null, highlight = false).render()) assertEquals(out.next(), Code("Code", language = null, showLineNumbers = false).render()) assertEquals(out.next(), Code("class Point {\n ...\n}", language = null, showLineNumbers = true).render()) assertEquals(out.next(), Code("class Point {\n ...\n}", language = "java", showLineNumbers = false).render()) assertEquals(out.next(), Code("<a href=\"#\">", language = "html", showLineNumbers = true).render()) assertEquals( out.next(), Code("class Point {\n ...\n}", language = "java", focusedLines = Range(1, 2)).render(), ) assertEquals( out.next(), Code("class Point {\n ...\n}", language = "java", focusedLines = Range(2, null)).render(), ) assertEquals( out.next(), Code("class Point {\n ...\n}", language = "java", focusedLines = Range(null, 1)).render(), ) assertEquals( out.next(), Code("class Point {\n ...\n}", language = "java", caption = "A Java code example.").render(), ) } @Test fun horizontalRule() { assertEquals("<hr />", HorizontalRule.render()) } @Test fun pageBreak() { assertEquals("<div class=\"page-break\" data-hidden=\"\">\n</div>", PageBreak().render()) } @Test fun heading() { val out = readParts("block/heading.html") // No automatic ID, no automatic page break. val noIdNoPageBreak = MutableContext( QuarkdownFlavor, options = MutableContextOptions(autoPageBreakHeadingMaxDepth = 0, enableAutomaticIdentifiers = false), ) assertEquals(out.next(), Heading(1, listOf(Text("Foo bar"))).render(noIdNoPageBreak)) assertEquals(out.next(), Heading(2, listOf(Text("Foo bar"))).render(noIdNoPageBreak)) assertEquals( out.next(), Heading( 2, listOf(Text("Foo bar")), canBreakPage = false, canTrackLocation = false, excludeFromTableOfContents = true, ).render(noIdNoPageBreak), ) assertEquals(out.next(), Heading(3, listOf(Text("Foo bar")), customId = "my-id").render(noIdNoPageBreak)) assertEquals(out.next(), Heading(3, listOf(Strong(listOf(Text("Foo bar"))))).render(noIdNoPageBreak)) assertEquals(out.next(), Heading(4, listOf(Text("Foo"), Emphasis(listOf(Text("bar"))))).render(noIdNoPageBreak)) // Automatic ID, no automatic page break. val idNoPageBreak = MutableContext( QuarkdownFlavor, options = MutableContextOptions(autoPageBreakHeadingMaxDepth = 0), ) assertEquals(out.next(), Heading(1, listOf(Text("Foo bar"))).render(idNoPageBreak)) assertEquals(out.next(), Heading(1, listOf(Text("Foo bar")), customId = "custom-id").render(idNoPageBreak)) assertEquals(out.next(), Heading(4, listOf(Text("Foo"), Emphasis(listOf(Text("bar"))))).render(idNoPageBreak)) // Automatic ID, force page break on depth <= 2 val autoPageBreak = MutableContext(QuarkdownFlavor, options = MutableContextOptions(autoPageBreakHeadingMaxDepth = 2)) assertEquals(out.next(), Heading(1, listOf(Text("Foo bar"))).render(autoPageBreak)) assertEquals(out.next(), Heading(2, listOf(Text("Foo bar"))).render(autoPageBreak)) assertEquals(out.next(), Heading(3, listOf(Text("Foo bar"))).render(autoPageBreak)) assertEquals(out.next(), Heading(4, listOf(Text("Foo bar"))).render(autoPageBreak)) } private fun listItems() = listOf( ListItem( children = listOf( Paragraph(listOf(Text("A1"))), HorizontalRule, Paragraph(listOf(Text("A2"))), ), ), ListItem( children = listOf( Paragraph(listOf(Text("B1"))), HorizontalRule, Paragraph(listOf(Text("B2"))), ), ), ListItem( children = listOf( Paragraph(listOf(Text("C1"))), HorizontalRule, Paragraph(listOf(Text("C2"))), ), ), ListItem( variants = listOf(FocusListItemVariant(isFocused = true)), children = listOf( Paragraph(listOf(Text("D1"))), HorizontalRule, Paragraph(listOf(Text("D2"))), ), ), ListItem( variants = listOf(TaskListItemVariant(isChecked = true)), listOf( Paragraph(listOf(Text("E1"))), HorizontalRule, Paragraph(listOf(Text("E2"))), ), ), ) @Test fun orderedList() { val out = readParts("block/orderedlist.html") assertEquals(out.next(), OrderedList(startIndex = 1, isLoose = false, emptyList()).render()) assertEquals( out.next(), OrderedList( startIndex = 1, isLoose = true, listItems(), ).render(), ) assertEquals( out.next(), OrderedList( startIndex = 12, isLoose = true, listItems(), ).render(), ) assertEquals( out.next(), OrderedList( startIndex = 1, isLoose = false, listItems(), ).also { list -> list.children .asSequence() .filterIsInstance<ListItem>() .forEach { it.owner = list } }.render(), ) } @Test fun unorderedList() { val out = readParts("block/unorderedlist.html") assertEquals(out.next(), UnorderedList(isLoose = false, emptyList()).render()) assertEquals( out.next(), UnorderedList( isLoose = true, listItems(), ).render(), ) assertEquals( out.next(), UnorderedList( isLoose = false, listItems(), ).also { list -> list.children .asSequence() .filterIsInstance<ListItem>() .forEach { it.owner = list } }.render(), ) } @Test fun html() { assertEquals("<p><strong>test</p></strong>", Html("<p><strong>test</p></strong>").render()) } @Test fun paragraph() { val out = readParts("block/paragraph.html") assertEquals(out.next(), Paragraph(listOf(Text("Foo bar"))).render()) assertEquals(out.next(), Paragraph(listOf(Text("Foo"), LineBreak, Text("bar"))).render()) } @Test fun blockquote() { val out = readParts("block/blockquote.html") assertEquals( out.next(), buildBlock { blockQuote { paragraph { text("Foo bar") } paragraph { text("Baz bim") } } }.render(), ) assertEquals( out.next(), buildBlock { blockQuote(attribution = { text("William Shakespeare") }) { paragraph { text("To be, or not to be.") } paragraph { text("That is the question.") } } }.render(), ) // The 'Tip' label is not rendered here because // it requires the stdlib localization table. assertEquals( out.next(), buildBlock { blockQuote( type = BlockQuote.Type.TIP, attribution = { text("Someone") }, ) { paragraph { text("Hi there!") } } }.render(), ) } @Test fun table() { val out = readParts("block/table.html") assertEquals( out.next(), Table( listOf( Table.Column( Table.Alignment.NONE, header = Table.Cell(listOf(Text("A"))), cells = listOf( Table.Cell(listOf(Text("C"))), Table.Cell(listOf(Text("E"))), ), ), Table.Column( Table.Alignment.NONE, header = Table.Cell(listOf(Text("B"))), cells = listOf( Table.Cell(listOf(Text("D"))), Table.Cell(listOf(Text("F"))), ), ), ), ).render(), ) assertEquals( out.next(), Table( listOf( Table.Column( Table.Alignment.CENTER, header = Table.Cell(listOf(Text("A"))), cells = listOf( Table.Cell(listOf(Text("C"))), Table.Cell(listOf(Text("E"))), ), ), Table.Column( Table.Alignment.RIGHT, header = Table.Cell(listOf(Text("B"))), cells = listOf( Table.Cell(listOf(Text("D"))), Table.Cell(listOf(Strong(listOf(Text("F"))))), ), ), ), ).render(), ) assertEquals( out.next(), Table( listOf( Table.Column( Table.Alignment.NONE, header = Table.Cell(listOf(Text("A"))), cells = listOf( Table.Cell(listOf(Text("C"))), Table.Cell(listOf(Text("E"))), ), ), Table.Column( Table.Alignment.NONE, header = Table.Cell(listOf(Text("B"))), cells = listOf( Table.Cell(listOf(Text("D"))), Table.Cell(listOf(Text("F"))), ), ), ), caption = "Table 'caption'.", ).render(), ) } // Quarkdown @Test fun mathBlock() { val out = readParts("block/math.html") assertEquals(out.next(), Math("some expression").render()) assertEquals(out.next(), Math("\\lim_{x\\to\\infty}x").render()) } @Test fun mathSpan() { val out = readParts("inline/math.html") assertEquals(out.next(), MathSpan("some expression").render()) assertEquals(out.next(), MathSpan("\\lim_{x\\to\\infty}x").render()) } @Test fun container() { val out = readParts("quarkdown/container.html") val children = buildBlocks { paragraph { text("Foo bar") } blockQuote { paragraph { text("Baz") } } } assertEquals(out.next(), Container(children = children).render()) assertEquals( out.next(), Container( foregroundColor = Color(100, 20, 80), backgroundColor = Color(10, 20, 30), children = children, ).render(), ) assertEquals( out.next(), Container( backgroundColor = Color(10, 20, 30), padding = Sizes(vertical = 2.0.cm, horizontal = 3.0.cm), cornerRadius = Sizes(all = 12.0.px), children = children, ).render(), ) assertEquals( out.next(), Container( fullWidth = true, borderColor = Color(30, 20, 10), borderWidth = Sizes(all = 1.0.cm), margin = Sizes(all = 2.0.cm), padding = Sizes(2.0.inch, 3.percent, 4.0.inch, 5.0.inch), cornerRadius = Sizes(all = 6.0.px), alignment = Container.Alignment.CENTER, textAlignment = Container.TextAlignment.JUSTIFY, children = children, ).render(), ) assertEquals( out.next(), Container( borderColor = Color(30, 20, 10), borderStyle = Container.BorderStyle.DOTTED, alignment = Container.Alignment.END, children = children, ).render(), ) assertEquals( out.next(), Container( float = Container.FloatAlignment.END, className = "custom-class", children = children, ).render(), ) assertEquals( out.next(), Container( textTransform = TextTransformData( size = TextTransformData.Size.LARGE, style = TextTransformData.Style.ITALIC, decoration = TextTransformData.Decoration.STRIKETHROUGH, weight = TextTransformData.Weight.BOLD, case = TextTransformData.Case.UPPERCASE, variant = TextTransformData.Variant.SMALL_CAPS, ), children = children, ).render(), ) } @Test fun navigationContainer() { val out = readParts("quarkdown/navigationcontainer.html") val children = buildBlocks { paragraph { text("Nav") } } assertEquals( out.next(), NavigationContainer( role = null, children = children, ).render(), ) assertEquals( out.next(), NavigationContainer( role = NavigationContainer.Role.PAGE_LIST, children = children, ).render(), ) assertEquals( out.next(), NavigationContainer( role = NavigationContainer.Role.TABLE_OF_CONTENTS, children = children, ).render(), ) } @Test fun fullSpan() { val out = readParts("quarkdown/fullspan.html") val paragraph = Paragraph(listOf(Text("Foo"), LineBreak, Text("bar"))) assertEquals(out.next(), Container(fullColumnSpan = true, children = listOf(paragraph)).render()) } @Test fun clipped() { val out = readParts("quarkdown/clipped.html") val paragraph = Paragraph(listOf(Text("Foo"), LineBreak, Text("bar"))) assertEquals(out.next(), Clipped(Clipped.Clip.CIRCLE, listOf(paragraph)).render()) assertEquals(out.next(), Clipped(Clipped.Clip.CIRCLE, listOf(paragraph, paragraph)).render()) } @Test fun box() { val out = readParts("quarkdown/box.html") val paragraph = Paragraph(listOf(Text("Foo"), LineBreak, Text("bar"))) assertEquals( out.next(), Box( title = listOf(Text("Title")), type = Box.Type.CALLOUT, padding = null, backgroundColor = null, foregroundColor = null, listOf(paragraph), ).render(), ) assertEquals( out.next(), Box( title = listOf(Text("Title"), Emphasis(listOf(Text("Title")))), type = Box.Type.WARNING, padding = null, backgroundColor = null, foregroundColor = null, listOf(paragraph), ).render(), ) assertEquals( out.next(), Box( title = null, type = Box.Type.ERROR, padding = 4.0.cm, backgroundColor = null, foregroundColor = null, listOf(paragraph), ).render(), ) assertEquals( out.next(), Box( title = listOf(Text("Title")), type = Box.Type.ERROR, padding = 3.0.inch, backgroundColor = Color(255, 0, 120), foregroundColor = Color(0, 10, 25), listOf(paragraph), ).render(), ) } @Test fun collapse() { val out = readParts("quarkdown/collapse.html") assertEquals( out.next(), Collapse( title = listOf(Emphasis(listOf(Text("Hello")))), isOpen = false, children = listOf(Strong(listOf(Text("world")))), ).render(), ) assertEquals( out.next(), Collapse( title = listOf(Text("Hello")), isOpen = true, children = listOf(BlockQuote(children = listOf(Paragraph(listOf(Text("world")))))), ).render(), ) } @Test fun `inline collapse`() { val out = readParts("quarkdown/inlinecollapse.html") assertEquals( out.next(), InlineCollapse( text = buildInline { text("Foo bar") }, placeholder = buildInline { text("Placeholder") }, isOpen = false, ).render(), ) assertEquals( out.next(), InlineCollapse( text = buildInline { text("Foo bar") }, placeholder = buildInline { text("Placeholder") }, isOpen = true, ).render(), ) } @Test fun `text transform`() { val out = readParts("quarkdown/texttransform.html") assertEquals( out.next(), TextTransform( TextTransformData( size = TextTransformData.Size.LARGE, style = TextTransformData.Style.ITALIC, decoration = TextTransformData.Decoration.STRIKETHROUGH, ), children = buildInline { text("Foo") }, ).render(), ) assertEquals( out.next(), TextTransform( TextTransformData( size = TextTransformData.Size.TINY, weight = TextTransformData.Weight.BOLD, decoration = TextTransformData.Decoration.UNDEROVERLINE, variant = TextTransformData.Variant.SMALL_CAPS, ), children = buildInline { emphasis { text("Foo") } text("bar") }, ).render(), ) assertEquals( out.next(), TextTransform( TextTransformData( case = TextTransformData.Case.CAPITALIZE, decoration = TextTransformData.Decoration.ALL, color = Color(255, 0, 0), ), children = buildInline { text("Foo") }, ).render(), ) assertEquals( out.next(), TextTransform( TextTransformData(), children = buildInline { text("Foo") }, ).render(), ) assertEquals( out.next(), TextTransform( TextTransformData(size = TextTransformData.Size.LARGE), className = "custom-class", children = buildInline { text("Foo") }, ).render(), ) // Subscript. assertEquals( out.next(), TextTransform( TextTransformData(script = TextTransformData.Script.SUB), children = buildInline { text("2") }, ).render(), ) // Superscript with additional style. assertEquals( out.next(), TextTransform( TextTransformData( script = TextTransformData.Script.SUP, weight = TextTransformData.Weight.BOLD, ), children = buildInline { text("2") }, ).render(), ) } @Test fun icon() { assertEquals("<i class=\"icon-image bi bi-alarm\" aria-hidden=\"true\">\n</i>", IconImage("alarm").render()) assertEquals("<i class=\"icon-image bi bi-1-circle\" aria-hidden=\"true\">\n</i>", IconImage("1-circle").render()) } @Test fun `last heading`() { val out = readParts("quarkdown/lastheading.html") assertEquals( out.next(), LastHeading(depth = 3).render(), ) } @Test fun `file tree`() { val out = readParts("quarkdown/filetree.html") // Files only. assertEquals( out.next(), FileTree( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.json"), ), ).render(), ) // Directory with files. assertEquals( out.next(), FileTree( listOf( FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts"), FileTreeEntry.File("utils.ts"), ), ), FileTreeEntry.File("README.md"), ), ).render(), ) // Ellipsis. assertEquals( out.next(), FileTree( listOf( FileTreeEntry.File("index.ts"), FileTreeEntry.Ellipsis(), ), ).render(), ) // Highlighted entries. assertEquals( out.next(), FileTree( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.txt", highlighted = true), FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts", highlighted = true), FileTreeEntry.File("utils.ts"), ), highlighted = true, ), FileTreeEntry.Ellipsis(highlighted = true), ), ).render(), ) } @Test fun bibliography() { val out = readParts("quarkdown/bibliography.html") val entries = listOf("einstein", "latexcompanion", "knuthwebsite") // Stub style producing simple, predictable output for HTML structure verification. val stubStyle = object : BibliographyStyle { override val name = "test" override val labelProvider = object : BibliographyEntryLabelProviderStrategy { override fun getCitationLabel(entries: List<BibliographyEntry>) = "" override fun getListLabel( entry: BibliographyEntry, index: Int, ) = "[${index + 1}]" } override fun contentOf(entry: BibliographyEntry) = buildInline { text("Content of ${entry.citationKey}.") } } assertEquals( out.next(), AstRoot( listOf( Heading(depth = 1, text = buildInline { text("Bibliography") }), BibliographyView( bibliography = Bibliography(entries.associateWith { BibliographyEntry(it) }), style = stubStyle, ), ), ).render(), ) } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/HtmlPostRendererTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.attributes.presence.markMathPresence import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.DocumentAuthor import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.deepCopy import com.quarkdown.core.document.layout.DocumentLayoutInfo import com.quarkdown.core.document.layout.font.FontInfo import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.paragraph.ParagraphStyleInfo import com.quarkdown.core.document.size.inch import com.quarkdown.core.document.tex.TexInfo import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.media.ResolvableMedia import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.rendering.html.post.HtmlPostRenderer import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** * HTML post renderer tests. */ class HtmlPostRendererTest { private lateinit var context: MutableContext private fun postRenderer(relativePathToRoot: String = "."): HtmlPostRenderer = HtmlPostRenderer(context, relativePathToRoot = relativePathToRoot) private fun setFontInfo(vararg fontInfo: FontInfo) { context.documentInfo = context.documentInfo.deepCopy(layoutFonts = fontInfo.toList()) } @BeforeTest fun setup() { context = MutableContext(QuarkdownFlavor) } @Test fun `wrap with empty content`() { val result = postRenderer().wrap("") assertTrue("<!DOCTYPE html>" in result) assertTrue("<html>" in result || "<html" in result) assertTrue("<head>" in result) assertTrue("<body" in result) assertTrue("</html>" in result) } @Test fun `wrap injects content`() { val result = postRenderer().wrap("<strong>Hello, world!</strong>") assertTrue("<strong>Hello, world!</strong>" in result) } @Test fun `wrap includes title`() { context.documentInfo = DocumentInfo(name = "Doc title") val result = postRenderer().wrap("") assertTrue("<title>Doc title</title>" in result) } @Test fun `wrap includes default title`() { val result = postRenderer().wrap("") assertTrue("<title>Quarkdown</title>" in result) } @Test fun `wrap includes authors`() { context.documentInfo = DocumentInfo( authors = listOf(DocumentAuthor("Alice"), DocumentAuthor("Bob")), ) val result = postRenderer().wrap("") assertTrue("Alice, Bob" in result) assertTrue("name=\"author\"" in result) } @Test fun `wrap includes description`() { context.documentInfo = DocumentInfo(description = "A test document") val result = postRenderer().wrap("") assertTrue("A test document" in result) assertTrue("name=\"description\"" in result) } @Test fun `wrap includes language`() { context.documentInfo = DocumentInfo(locale = LocaleLoader.SYSTEM.fromName("english")) val result = postRenderer().wrap("") assertTrue("lang=\"en\"" in result) } @Test fun `wrap with path to root`() { val rootResult = postRenderer(relativePathToRoot = ".").wrap("") val nestedResult = postRenderer(relativePathToRoot = "..").wrap("") assertTrue("./script/quarkdown.js" in rootResult) assertTrue("../script/quarkdown.js" in nestedResult) } @Test fun `wrap includes math scripts when math is present`() { context.attributes.markMathPresence() val result = postRenderer().wrap("") assertTrue("katex" in result) assertTrue("capabilities.math = true" in result) } @Test fun `wrap excludes math scripts when no math`() { val result = postRenderer().wrap("") assertFalse("katex" in result) assertFalse("capabilities.math = true" in result) } @Test fun `wrap excludes code scripts when no code`() { val result = postRenderer().wrap("") assertFalse("highlight.js" in result) assertFalse("capabilities.code = true" in result) } @Test fun `wrap with slides type`() { context.documentInfo = DocumentInfo(type = DocumentType.SLIDES) val result = postRenderer().wrap("<p>Content</p>") assertTrue("reveal.js" in result) assertTrue("class=\"reveal\"" in result) assertTrue("class=\"slides\"" in result) assertTrue("maximum-scale=1.0" in result) assertTrue("quarkdown-slides" in result) } @Test fun `wrap with plain type`() { context.documentInfo = DocumentInfo(type = DocumentType.PLAIN) val result = postRenderer().wrap("<p>Content</p>") assertFalse("reveal.js" in result) assertTrue("margin-area-left" in result) assertTrue("<main>" in result) assertTrue("quarkdown-plain" in result) } @Test fun `wrap with paged type`() { context.documentInfo = DocumentInfo(type = DocumentType.PAGED) val result = postRenderer().wrap("<p>Content</p>") assertTrue("pagedjs" in result) assertTrue("quarkdown-paged" in result) assertTrue("<div class=\"paged-content-wrapper\">" in result) } @Test fun `wrap with docs type`() { context.documentInfo = DocumentInfo(type = DocumentType.DOCS) val result = postRenderer().wrap("<p>Content</p>") assertTrue("search-input" in result) assertTrue("content-wrapper" in result) assertTrue("quarkdown-docs" in result) assertTrue("search-index" in result) } @Test fun `wrap with non-slides viewport`() { context.documentInfo = DocumentInfo(type = DocumentType.PLAIN) val result = postRenderer().wrap("") assertTrue("width=device-width, initial-scale=1.0" in result) assertFalse("maximum-scale=1.0" in result) } @Test fun `wrap with page dimensions`() { context.documentInfo = DocumentInfo( layout = DocumentLayoutInfo( pageFormats = listOf(PageFormatInfo(pageWidth = 8.5.inch)), ), ) val result = postRenderer().wrap("") assertTrue("--qd-content-width: 8.5in" in result) } @Test fun `wrap with paragraph styling`() { context.documentInfo = DocumentInfo( layout = DocumentLayoutInfo( paragraphStyle = ParagraphStyleInfo(lineHeight = 1.5, spacing = 0.5), ), ) val result = postRenderer().wrap("") assertTrue("--qd-line-height: 1.5" in result) assertTrue("--qd-paragraph-vertical-margin: 0.5em" in result) } // Fonts @Test fun `system font`() { setFontInfo(FontInfo(mainFamily = FontFamily.System("Arial"))) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '63529059'; src: local('Arial'); }" in result) assertTrue("--qd-main-custom-font: '63529059'" in result) } @Test fun `local font, no media storage`() { val workingDirectory = File("src/test/resources") val path = "media/NotoSans-Regular.ttf" val media = ResolvableMedia(path, workingDirectory) setFontInfo(FontInfo(mainFamily = FontFamily.Media(media, path))) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '${path.hashCode()}'" in result) assertTrue("src: url('${File(workingDirectory, path).absolutePath}')" in result) assertTrue("--qd-main-custom-font: '${path.hashCode()}'" in result) } @Test fun `local font, with media storage`() { val workingDirectory = File("src/test/resources") val path = "media/NotoSans-Regular.ttf" val file = File(workingDirectory, path) val media = ResolvableMedia(path, workingDirectory) setFontInfo(FontInfo(mainFamily = FontFamily.Media(media, path))) context.options.enableLocalMediaStorage = true context.mediaStorage.register(path, media) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '${path.hashCode()}'" in result) assertTrue("src: url('media/NotoSans-Regular@${file.hashCode()}.ttf')" in result) } @Test fun `remote font, no media storage`() { val url = "https://fonts.gstatic.com/s/notosans/v39/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A-9U6VTYyWtZ3rKW9w.woff" val media = ResolvableMedia(url) setFontInfo(FontInfo(mainFamily = FontFamily.Media(media, url))) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '${url.hashCode()}'" in result) assertTrue("src: url('$url')" in result) } @Test fun `remote font, with media storage`() { val url = "https://fonts.gstatic.com/s/notosans/v39/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A-9U6VTYyWtZ3rKW9w.woff" val media = ResolvableMedia(url) setFontInfo(FontInfo(mainFamily = FontFamily.Media(media, url))) context.options.enableRemoteMediaStorage = true context.mediaStorage.register(url, media) val result = postRenderer().wrap("") assertContains( result, """ @font-face { font-family: '${url.hashCode()}'; src: url('media/https-fonts.gstatic.com-s-notosans-v39-o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A-9U6VTYyWtZ3rKW9w.woff'); } """.trimIndent(), ) assertContains( result, "--qd-main-custom-font: '${url.hashCode()}';", ) } @Test fun `google font`() { val name = "Karla" setFontInfo(FontInfo(mainFamily = FontFamily.GoogleFont(name))) val result = postRenderer().wrap("") assertTrue("@import url('https://fonts.googleapis.com/css2?family=$name&display=swap')" in result) assertTrue("--qd-main-custom-font: '$name'" in result) } @Test fun `main and heading fonts`() { setFontInfo( FontInfo( mainFamily = FontFamily.System("Arial"), headingFamily = FontFamily.GoogleFont("Roboto"), ), ) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '63529059'; src: local('Arial'); }" in result) assertTrue("@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap')" in result) assertTrue("--qd-main-custom-font: '63529059'" in result) assertTrue("--qd-heading-custom-font: 'Roboto'" in result) } @Test fun `multiple font configurations`() { setFontInfo( FontInfo(mainFamily = FontFamily.System("Arial")), FontInfo(mainFamily = FontFamily.GoogleFont("Roboto"), headingFamily = FontFamily.GoogleFont("Noto Sans")), FontInfo(mainFamily = FontFamily.GoogleFont("Source Code Pro")), ) val result = postRenderer().wrap("") assertTrue("@font-face { font-family: '63529059'; src: local('Arial'); }" in result) assertTrue("@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap')" in result) assertTrue("@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap')" in result) assertTrue("@import url('https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap')" in result) assertTrue("--qd-main-custom-font: 'Source Code Pro', 'Roboto', '63529059'" in result) assertTrue("--qd-heading-custom-font: 'Noto Sans'" in result) } @Test fun `wrap with full metadata and slides`() { context.documentInfo = DocumentInfo( name = "Quarkdown", description = "The Quarkdown typesetting system", locale = LocaleLoader.SYSTEM.fromName("english"), type = DocumentType.SLIDES, layout = DocumentLayoutInfo( fonts = listOf( FontInfo(mainFamily = FontFamily.System("Arial")), ), ), ) context.attributes.markMathPresence() val result = postRenderer().wrap("<p><em>Hello, world!</em></p>") assertContains(result, "lang=\"en\"") assertContains(result, "<title>Quarkdown</title>") assertContains(result, "The Quarkdown typesetting system") assertContains(result, "@font-face { font-family: '63529059'; src: local('Arial'); }") assertContains(result, "katex") assertContains(result, "class=\"reveal\"") assertContains(result, "<p><em>Hello, world!</em></p>") } @Test fun `wrap with tex macros`() { context.documentInfo = DocumentInfo( tex = TexInfo(macros = mutableMapOf("\\R" to "\\mathbb{R}", "\\Z" to "\\mathbb{Z}")), ) context.attributes.markMathPresence() val result = postRenderer().wrap("") assertTrue("\"\\\\R\": \"\\\\mathbb{R}\"" in result) assertTrue("\"\\\\Z\": \"\\\\mathbb{Z}\"" in result) } // Resource generation private fun assertThemeGroupContains( resources: Set<OutputResource>, expectedThemes: Set<String>, notExpectedThemes: Set<String> = emptySet(), ) { val themeGroup = resources.filterIsInstance<OutputResourceGroup>().first { it.name == "theme" } val themes = themeGroup.resources.map { it.name } expectedThemes.forEach { assertTrue(it in themes) } notExpectedThemes.forEach { assertFalse(it in themes) } val theme = themeGroup.resources.first { it.name == "theme" } as TextOutputArtifact assertEquals(ArtifactType.CSS, theme.type) expectedThemes.filter { it != "theme" }.forEach { assertTrue("@import url('$it.css');" in theme.content) } } private val plainHtml = "<html><head></head><body></body></html>" private fun `resource generation`(block: (Set<OutputResource>) -> Unit) { context.documentInfo = DocumentInfo( type = DocumentType.SLIDES, theme = DocumentTheme(color = "darko", layout = "minimal"), ) context.attributes.markMathPresence() val postRenderer = HtmlPostRenderer(context) val resources = postRenderer.generateResources(plainHtml) block(resources) resources.filterIsInstance<TextOutputArtifact>().first { it.type == ArtifactType.HTML }.let { assertEquals(plainHtml, it.content) } assertThemeGroupContains(resources, setOf("darko", "minimal", "global", "theme")) val scriptGroup = resources.filterIsInstance<OutputResourceGroup>().first { it.name == "script" } assertEquals("quarkdown", scriptGroup.resources.single().name) } @Test fun `resource generation, no media`() = `resource generation` { resources -> assertEquals(3, resources.size) assertFalse(MEDIA_SUBDIRECTORY_NAME in resources.map { it.name }) // Media storage is empty. } @Test fun `resource generation, with media`() { context.options.enableLocalMediaStorage = true context.mediaStorage.register("src/test/resources/media/file.txt", workingDirectory = null) `resource generation` { resources -> assertEquals(4, resources.size) assertTrue(MEDIA_SUBDIRECTORY_NAME in resources.map { it.name }) // Media storage is empty. } } @Test fun `resource generation, default theme`() { val context = MutableContext(QuarkdownFlavor) val postRenderer = HtmlPostRenderer(context) val html = "<html><head></head><body></body></html>" val resources = postRenderer.generateResources(html) assertEquals(3, resources.size) assertThemeGroupContains( resources, setOf("paperwhite", "latex", "global", "theme"), notExpectedThemes = setOf("zh"), ) } @Test fun `resource generation, with specific localized theme`() { val context = MutableContext(QuarkdownFlavor) context.documentInfo = DocumentInfo(locale = LocaleLoader.SYSTEM.find("zh-CN")) val postRenderer = HtmlPostRenderer(context) val resources = postRenderer.generateResources(plainHtml) assertEquals(3, resources.size) assertThemeGroupContains( resources, setOf("paperwhite", "latex", "global", "theme", "zh"), ) } @Test fun `resource generation, with missing localized theme`() { val context = MutableContext(QuarkdownFlavor) context.documentInfo = DocumentInfo(locale = LocaleLoader.SYSTEM.find("akan")) val postRenderer = HtmlPostRenderer(context) val resources = postRenderer.generateResources(plainHtml) assertEquals(3, resources.size) assertThemeGroupContains( resources, expectedThemes = emptySet(), notExpectedThemes = setOf("akan"), ) } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/HtmlSecurityTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.attributes.presence.markMathPresence import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.deepCopy import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.rendering.html.post.HtmlPostRenderer import kotlin.test.Test import kotlin.test.assertTrue /** * Tests for system security from HTML code injection and other vulnerabilities. */ class HtmlSecurityTest { private fun testMacro( name: String, content: String, expectedSnippet: String, ) { val context = MutableContext(QuarkdownFlavor) context.documentInfo = context.documentInfo.deepCopy(texMacros = mapOf(name to content)) context.attributes.markMathPresence() val postRenderer = HtmlPostRenderer(context) val result = postRenderer.wrap("") assertTrue(expectedSnippet in result, "Expected snippet not found in output: $expectedSnippet") } @Test fun `injection in tex macro content`() { testMacro( name = "\\hello", content = "\", function() {}", expectedSnippet = "\"\\\\hello\": \"\\\", function() {}\"", ) } @Test fun `injection in tex macro name`() { testMacro( name = """\hello": "", function() {}""", content = "\\text {hello}", expectedSnippet = "\"\\\\hello\\\": \\\"\\\", function() {}\": \"\\\\text {hello}\"", ) } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/HtmlToPdfTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.interaction.Env import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NpmWrapper import com.quarkdown.rendering.html.pdf.HtmlPdfExportOptions import com.quarkdown.rendering.html.pdf.HtmlPdfExporter import com.quarkdown.rendering.html.pdf.PuppeteerNodeModule import org.apache.pdfbox.Loader import org.apache.pdfbox.text.PDFTextStripper import org.junit.Assume.assumeTrue import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** * Tests for HTML-to-PDF generation. */ class HtmlToPdfTest { private val directory: File = createTempDirectory() .toFile() private val options = HtmlPdfExportOptions( outputDirectory = directory, nodeJsPath = NodeJsWrapper.defaultPath, npmPath = NpmWrapper.defaultPath, ) @BeforeTest fun setup() { directory.deleteRecursively() directory.mkdirs() } @Test fun `bare script on simple html`() { assumeTrue(Env.npmPrefix != null) assumeTrue(Env.nodePath != null) val node = NodeJsWrapper(options.nodeJsPath, workingDirectory = directory) assumeTrue(node.isValid) with(NpmWrapper(options.npmPath)) { assumeTrue(isValid) assumeTrue(isInstalled(node, PuppeteerNodeModule)) } val html = File(directory, "index.html") html.writeText( """ <!DOCTYPE html> <html> <head> <title>Test</title> </head> <body> <h1>Hello, Quarkdown!</h1> <script> function isReady() { return true; } window.isReady = isReady; </script> </body> </html> """.trimIndent(), ) println(directory) println(directory.list().contentToString()) val out = File(directory, "out.pdf") HtmlPdfExporter(options.copy(noSandbox = true)).export(directory, out) assertTrue(out.exists()) assertFalse(File(directory, "pdf.js").exists()) assertFalse(File(directory, "package.json").exists()) assertFalse(File(directory, "package-lock.json").exists()) assertFalse(File(directory, "node_modules").exists()) Loader.loadPDF(out).use { val text = PDFTextStripper().getText(it).trim() assertEquals(1, it.numberOfPages) assertEquals("Hello, Quarkdown!", text) } } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/MediaTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.media.StoredMediaProperty import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.media.storage.StoredMedia import com.quarkdown.rendering.html.node.QuarkdownHtmlNodeRenderer import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue private const val WORKING_DIR_PATH = "src/test/resources" private const val REMOTE_URL = "https://iamgio.eu/quarkdown/img/logo-light.svg" private const val REMOTE_OUT_NAME = "https-iamgio.eu-quarkdown-img-logo-light.svg" private const val LOCAL_PATH = "media/icon.png" private const val OUT_DIR = MEDIA_SUBDIRECTORY_NAME /** * Tests for media resolution and rendering via the HTML renderer. */ class MediaTest { private lateinit var context: MutableContext private lateinit var renderer: QuarkdownHtmlNodeRenderer @BeforeTest fun setUp() { context = MutableContext(QuarkdownFlavor) renderer = QuarkdownHtmlNodeRenderer(context) context.options.enableLocalMediaStorage = true context.options.enableRemoteMediaStorage = true } private fun Node.attach(media: StoredMedia?) { if (media == null) return context.attributes.of(this) += StoredMediaProperty(media) } private fun remoteImage(media: StoredMedia?) = Image( Link( label = listOf(), url = REMOTE_URL, title = null, ).apply { attach(media) }, width = null, height = null, ) private fun localImage(media: StoredMedia?) = Image( Link( label = listOf(), url = LOCAL_PATH, title = null, ).apply { attach(media) }, width = null, height = null, ) @Test fun `remote media path update`() { val media = context.mediaStorage.register(REMOTE_URL, workingDirectory = null)!! val image = remoteImage(media) assertEquals( "<img src=\"$OUT_DIR/$REMOTE_OUT_NAME\" alt=\"\" />", image.accept(renderer), ) } @Test fun `local media path update`() { val media = context.mediaStorage.register(LOCAL_PATH, workingDirectory = File(WORKING_DIR_PATH))!! val image = localImage(media) assertTrue(image.accept(renderer).startsWith("<img src=\"$OUT_DIR/icon@")) } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/SearchIndexGeneratorTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.attachMockPipeline import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.SubdocumentContext import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.graph.DirectedGraph import com.quarkdown.core.graph.Graph import com.quarkdown.core.graph.VisitableOnceGraph import com.quarkdown.rendering.html.search.SearchEntry import com.quarkdown.rendering.html.search.SearchHeading import com.quarkdown.rendering.html.search.SearchIndex import com.quarkdown.rendering.html.search.SearchIndexGenerator import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [SearchIndexGenerator]. */ class SearchIndexGeneratorTest { @Test fun `single subdocument`() { val context = MutableContext(QuarkdownFlavor, subdocument = Subdocument.Root) val graph: Graph<Subdocument> = DirectedGraph<Subdocument>().addVertex(Subdocument.Root) context.documentInfo = DocumentInfo(name = "Test Document", description = "A test document", keywords = listOf("test", "document")) context.attributes.root = AstRoot( buildBlocks { paragraph { emphasis { text("Hello, World!") } } paragraph { text("This is a test document.") } }, ) context.subdocumentGraph = VisitableOnceGraph(graph) context.attachMockPipeline() val index = SearchIndexGenerator.generate(context.sharedSubdocumentsData) assertEquals( SearchIndex( entries = listOf( SearchEntry( url = "/", title = "Test Document", description = "A test document", keywords = listOf("test", "document"), content = "Hello, World!\n\nThis is a test document.", headings = emptyList(), ), ), ), index, ) } @Test fun `two subdocuments`() { val rootSubdoc = Subdocument.Root val childSubdoc = Subdocument.Resource(name = "child", path = "", content = "") val rootContext = MutableContext(QuarkdownFlavor, subdocument = rootSubdoc) val childContext = SubdocumentContext(parent = rootContext, subdocument = childSubdoc) val graph: Graph<Subdocument> = DirectedGraph<Subdocument>() .addVertex(rootSubdoc) .addVertex(childSubdoc) .addEdge(rootSubdoc, childSubdoc) rootContext.documentInfo = DocumentInfo( name = "Root Document", description = "The root document", keywords = listOf("root", "document"), ) childContext.documentInfo = DocumentInfo( name = "Child Document", description = "A child document", keywords = listOf("child", "document"), ) rootContext.attributes.root = AstRoot(buildBlocks { paragraph { text("Root content") } }) childContext.attributes.root = AstRoot(buildBlocks { paragraph { text("Child content") } }) rootContext.subdocumentGraph = VisitableOnceGraph(graph) rootContext.sharedSubdocumentsData = rootContext.sharedSubdocumentsData.addContext(childSubdoc, childContext) rootContext.attachMockPipeline() childContext.attachMockPipeline() val index = SearchIndexGenerator.generate(rootContext.sharedSubdocumentsData) assertEquals( SearchIndex( entries = listOf( SearchEntry( url = "/", title = "Root Document", description = "The root document", keywords = listOf("root", "document"), content = "Root content", headings = emptyList(), ), SearchEntry( url = "/child", title = "Child Document", description = "A child document", keywords = listOf("child", "document"), content = "Child content", headings = emptyList(), ), ), ), index, ) } @Test fun `subdocument with headings`() { val context = MutableContext(QuarkdownFlavor, subdocument = Subdocument.Root) val graph: Graph<Subdocument> = DirectedGraph<Subdocument>().addVertex(Subdocument.Root) context.documentInfo = DocumentInfo( name = "Document with Headings", description = "A document that has headings", keywords = listOf("headings"), ) context.attributes.tableOfContents = TableOfContents.generate( sequenceOf( Heading(depth = 1, text = buildInline { text("Heading 1") }), Heading(depth = 2, text = buildInline { text("Heading 2") }), ), ) context.subdocumentGraph = VisitableOnceGraph(graph) context.attachMockPipeline() val index = SearchIndexGenerator.generate(context.sharedSubdocumentsData) assertEquals( SearchIndex( entries = listOf( SearchEntry( url = "/", title = "Document with Headings", description = "A document that has headings", keywords = listOf("headings"), content = "", headings = listOf( SearchHeading( anchor = "heading-1", text = "Heading 1", level = 1, ), SearchHeading( anchor = "heading-2", text = "Heading 2", level = 2, ), ), ), ), ), index, ) } } ================================================ FILE: quarkdown-html/src/test/kotlin/com/quarkdown/rendering/html/SidebarRendererTest.kt ================================================ package com.quarkdown.rendering.html import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.rendering.html.node.SidebarRenderer import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [SidebarRenderer]. */ class SidebarRendererTest { private fun entry( depth: Int, title: String, vararg subItems: TableOfContents.Item = emptyArray(), customId: String? = null, ) = TableOfContents.Item( Heading(depth, buildInline { text(title) }, customId = customId), subItems.toList(), ) @Test fun `single entry`() { val context = MutableContext(QuarkdownFlavor) context.attributes.tableOfContents = TableOfContents( listOf(entry(1, "Title")), ) val sidebar = SidebarRenderer.render(context) assertEquals( "<ol><li data-target-id=\"title\" data-depth=\"1\"><a href=\"#title\">Title</a></li></ol>", sidebar, ) } @Test fun `custom id`() { val context = MutableContext(QuarkdownFlavor) context.attributes.tableOfContents = TableOfContents( listOf(entry(1, "Title", customId = "custom-id")), ) val sidebar = SidebarRenderer.render(context) assertEquals( "<ol><li data-target-id=\"custom-id\" data-depth=\"1\"><a href=\"#custom-id\">Title</a></li></ol>", sidebar, ) } @Test fun `multiple entries, same depth`() { val context = MutableContext(QuarkdownFlavor) context.attributes.tableOfContents = TableOfContents( listOf( entry(1, "Title 1"), entry(1, "Title 2"), ), ) val sidebar = SidebarRenderer.render(context) assertEquals( "<ol><li data-target-id=\"title-1\" data-depth=\"1\"><a href=\"#title-1\">Title 1</a></li>" + "<li data-target-id=\"title-2\" data-depth=\"1\"><a href=\"#title-2\">Title 2</a></li></ol>", sidebar, ) } @Test fun `nested entries`() { val context = MutableContext(QuarkdownFlavor) context.attributes.tableOfContents = TableOfContents( listOf( entry( 1, "Title 1", entry(2, "Subtitle 1.1"), entry(2, "Subtitle 1.2"), ), entry(1, "Title 2", entry(2, "Subtitle 2.1")), ), ) val sidebar = SidebarRenderer.render(context) assertEquals( "<ol>" + "<li data-target-id=\"title-1\" data-depth=\"1\"><a href=\"#title-1\">Title 1</a>" + "<ol>" + "<li data-target-id=\"subtitle-11\" data-depth=\"2\"><a href=\"#subtitle-11\">Subtitle 1.1</a></li>" + "<li data-target-id=\"subtitle-12\" data-depth=\"2\"><a href=\"#subtitle-12\">Subtitle 1.2</a></li>" + "</ol>" + "</li>" + "<li data-target-id=\"title-2\" data-depth=\"1\"><a href=\"#title-2\">Title 2</a>" + "<ol>" + "<li data-target-id=\"subtitle-21\" data-depth=\"2\"><a href=\"#subtitle-21\">Subtitle 2.1</a></li>" + "</ol>" + "</li>" + "</ol>", sidebar, ) } } ================================================ FILE: quarkdown-html/src/test/resources/issues/.gitignore ================================================ output/ ================================================ FILE: quarkdown-html/src/test/resources/issues/README.md ================================================ This directory contains full Quarkdown snippets to be tested *manually*, about CSS-related fixed issues and edge-cases. 1. `cd` to this directory (`cd quarkdown-html/src/test/resources/issues`) 2. Compile sources, for example to PDF: - Parallel: `ls *.qd | xargs -P 4 -I {} quarkdown c "{}" --pdf` - Sequential (slower, use this in case of problems with the parallel one): `for f in *.qd; do quarkdown c "$f" --pdf; done` ================================================ FILE: quarkdown-html/src/test/resources/issues/data/bibliography/bibliography.bib ================================================ @article{einstein, author = "Albert Einstein", title = "Zur Elektrodynamik bewegter Körper. (German) [On the electrodynamics of moving bodies]", journal = "Annalen der Physik", volume = "322", number = "10", year = "1905", DOI = "http://dx.doi.org/10.1002/andp.19053221004" } @book{hawking, author = "Stephen Hawking", title = "A Brief History of Time", publisher = "Bantam Books", year = "1988", ISBN = "978-0553109535" } @misc{knuthwebsite, author = "Donald Knuth", title = "Knuth: Computers and Typesetting", url = "http://www-cs-faculty.stanford.edu/\~uno/abcde.html" } @article{abratkiewiczRadarSignalParameters2019, title = {Radar Signal Parameters Estimation Using Phase Accelerogram in the Time-Frequency Domain}, author = {Abratkiewicz, Karol and Samczy{\'n}ski, Piotr and Czarnecki, Krzysztof}, year = {2019}, month = jul, journal = {IEEE Sensors Journal}, volume = {19}, number = {13}, pages = {5078--5085}, issn = {1558-1748}, doi = {10.1109/JSEN.2019.2903352}, urldate = {2025-07-01}, } @article{aldimashkiPerformanceChirpParameter2020, title = {Performance of Chirp Parameter Estimation in the Fractional Fourier Domains and an Algorithm for Fast Chirp-Rate Estimation}, author = {Aldimashki, Omair and Serbes, Ahmet}, year = {2020}, month = oct, journal = {IEEE Transactions on Aerospace and Electronic Systems}, volume = {56}, number = {5}, pages = {3685--3700}, issn = {1557-9603}, doi = {10.1109/TAES.2020.2981268}, urldate = {2024-10-21}, langid = {american}, } @article{baiChirpRateEstimation2019, title = {Chirp Rate Estimation for LFM Signal by Multiple DPT and Weighted Combination}, author = {Bai, Guo and Cheng, Yufan and Tang, Wanbin and Li, Shaoqian}, year = {2019}, month = jan, journal = {IEEE Signal Processing Letters}, volume = {26}, number = {1}, pages = {149--153}, issn = {1558-2361}, doi = {10.1109/LSP.2018.2882300}, urldate = {2024-10-17}, langid = {american}, } @article{dengASCBSSBasedParameterEstimation2025, title = {ASC-BSS-Based Parameter Estimation Method for Multiple LFM Pulses With Aliasing Effect From Passive Radar}, author = {Deng, Jiangyun and Sun, Zhi and Li, Xiaolong and Cui, Guolong}, year = {2025}, month = apr, journal = {IEEE Transactions on Aerospace and Electronic Systems}, volume = {61}, number = {2}, pages = {5132--5144}, issn = {1557-9603}, doi = {10.1109/TAES.2024.3516706}, urldate = {2025-06-10}, langid = {american}, } @inproceedings{erdoganDigitalChirpRate2014, title = {Digital Chirp Rate Adaptation for Increased FMCW Interception Performance in Hough Based Transforms}, booktitle = {2014 International Radar Conference}, author = {Erdogan, A.Yasin and Gulum, Taylan O. and {Durak-Ata}, L{\"u}tfiye and Yildirim, T{\"u}lay and Pace, Phillip E.}, year = {2014}, month = oct, pages = {1--5}, issn = {1097-5764}, doi = {10.1109/RADAR.2014.7060324}, urldate = {2025-03-28} } @article{fourerChirpRateInstantaneous2017, title = {Chirp Rate and Instantaneous Frequency Estimation: Application to Recursive Vertical Synchrosqueezing}, shorttitle = {Chirp Rate and Instantaneous Frequency Estimation}, author = {Fourer, Dominique and Auger, Fran{\c c}ois and Czarnecki, Krzysztof and Meignen, Sylvain and Flandrin, Patrick}, year = {2017}, month = nov, journal = {IEEE Signal Processing Letters}, volume = {24}, number = {11}, pages = {1724--1728}, issn = {1558-2361}, doi = {10.1109/LSP.2017.2714578}, urldate = {2024-10-21}, langid = {american} } @article{fuSubNyquistMeasurementLFM2023, title = {Sub-Nyquist Measurement of LFM Pulse Stream Based on Signal Separation and Parameter Matching}, author = {Fu, Ning and Yun, Shuangxing and Han, Bingtong and Qiao, Liyan}, year = {2023}, journal = {IEEE Transactions on Instrumentation and Measurement}, volume = {72}, pages = {1--15}, issn = {1557-9662}, doi = {10.1109/TIM.2023.3328704}, urldate = {2024-10-28}, langid = {american} } @article{geroleoDetectionEstimationLFMCW2012, title = {Detection and Estimation of LFMCW Radar Signals}, author = {Geroleo, Francis G. and {Brandt-Pearce}, Maite}, year = {2012}, month = jan, journal = {IEEE Transactions on Aerospace and Electronic Systems}, volume = {48}, number = {1}, pages = {405--418}, issn = {1557-9603}, doi = {10.1109/TAES.2012.6129644}, urldate = {2025-03-28}, langid = {american} } @article{gunerFPGABasedWignerHough2021, title = {FPGA-Based Wigner--Hough Transform System for Detection and Parameter Extraction of LPI Radar LFMCW Signals}, author = {Guner, Kani K. and Gulum, Taylan O. and Erkmen, Burcu}, year = {2021}, journal = {IEEE Transactions on Instrumentation and Measurement}, volume = {70}, pages = {1--15}, issn = {1557-9662}, doi = {10.1109/TIM.2021.3060584}, urldate = {2024-10-21}, langid = {american} } @article{koberRecursiveAlgorithmsComputing2021, title = {Recursive Algorithms for Computing Sliding DCT With Arbitrary Step}, author = {Kober, Vitaly}, year = {2021}, month = may, journal = {IEEE Sensors Journal}, volume = {21}, number = {10}, pages = {11507--11513}, issn = {1558-1748}, doi = {10.1109/JSEN.2020.3023892}, urldate = {2025-07-01} } @inproceedings{liAccurateParameterEstimation2016, title = {Accurate Parameter Estimation of Chirp Class Signals under Low SNR}, booktitle = {2016 IEEE International Conference on Signal and Image Processing (ICSIP)}, author = {Li, Lin and Niu, Tingyu and Ji, Hongbing and Han, Hongxia and Liu, Yiduo}, year = {2016}, month = aug, pages = {412--416}, doi = {10.1109/SIPROCESS.2016.7888295}, urldate = {2025-03-28} } ================================================ FILE: quarkdown-html/src/test/resources/issues/landscape.qd ================================================ .docname {Landscape bounds should respect the parent page} .doctype {paged} .include {template/template.qd} .bulletin nextrelease:{1.8.0} .landscape # Title .repeat {4} .loremipsum .landscape # Title .mermaid graph LR A-->B B-->C C-->D D-->E E-->F F-->G G-->H H-->I I-->L L-->M .repeat {3} .loremipsum <<< Regular text ================================================ FILE: quarkdown-html/src/test/resources/issues/math-page-overflow.qd ================================================ .docname {Math page overflow} .doctype {paged} .doclang {Chinese} .include {template/template.qd} .bulletin issue:{134} pr:{151} nextrelease:{1.7.0} relatedto:{`math.js` executing in post-rendering rather than pre-rendering} # AI基础知识 ## Introduction 机器学习的任务有三类: - Regression:要找的函式,他的输出是一个数值 - Classification:函式的输出,就是从设定好的选项裡面,选择一个当作输出 - Structured Learning:机器產生有结构的东西的问题——学会创造 一般步骤包括: 1. Function with Unknown Parameters(建立Model) 2. Define Loss from Training Data (定义损失函数) 3. Optimization(优化求解) ### Model $ Sigmoid:y = c\frac{1}{{1+e^{-(b+wx_1)}}} $ 简单的非线性模型无法描述复杂的物理世界,利用若干个具有不同 $ w,b,c $ 的Sigmoid函数与一个常数参数的组合,可以模拟任何一个连续的曲线(非线性函数)。 - $ w $ 会改变**斜率** - $ b $ 将 Sigmoid Function 左右移动 - $ c $ 改变高度 拓展到多维特征: $ y=b+\sum_i sigmoid(b_i+\sum_j(w_{ij}x_j)) $ - **TEST CONTENT 1** - **TEST CONTENT 2** - **TEST CONTENT 3** - **TEST CONTENT 4** - **TEST CONTENT 5** - **TEST CONTENT 6** - **TEST CONTENT 7** - **TEST CONTENT 8** - **TEST CONTENT 9** - **TEST CONTENT 10** 写成矩阵形式: .figure .loremipsum ================================================ FILE: quarkdown-html/src/test/resources/issues/mermaid-size.qd ================================================ .docname {Incorrect mermaid auto-scaling} .include {template/template.qd} .bulletin issue:{132} pr:{141} nextrelease:{1.6.3} relatedto:{Imprecise auto-scaling function in `mermaid.js`} .mermaid sequenceDiagram a-->>b: A b-->>c: B c-->>d: C d-->>e: D e-->>f: E .mermaid sequenceDiagram c->>s: REQUEST s->>c: RESPONSE .mermaid sequenceDiagram c->>s: REQUEST s->>c: RESPONSE c->>s: REQUEST s->>c: RESPONSE c->>s: REQUEST s->>c: RESPONSE c->>s: REQUEST s->>c: RESPONSE c->>s: REQUEST s->>c: RESPONSE .var {mockdiagrams} - class - flow - git - pie - sequence .foreach {.mockdiagrams} .mermaid .read {.mockdir/mermaid/.1.mmd} ================================================ FILE: quarkdown-html/src/test/resources/issues/mirrored-bibliography.qd ================================================ .docname {Mirrored bibliography spanning over pages} .doctype {paged} .include {template/template.qd} .bulletin issue:{123} pr:{125} nextrelease:{1.6.2} relatedto:{Bibliography entry should not span over multiple pages} <<< .repeat {3} .loremipsum .bibliography {data/bibliography/bibliography.bib} ================================================ FILE: quarkdown-html/src/test/resources/issues/misaligned-math-fraction.qd ================================================ .docname {Misaligned math fraction} .include {template/template.qd} .bulletin issue:{263} pr:{-} nextrelease:{1.13.0} relatedto:{`text-align-last` affecting `formula`} inline: $ \frac{123}{12412323} $ $ \frac{123}{12412323} $ ================================================ FILE: quarkdown-html/src/test/resources/issues/misaligned-math-in-box.qd ================================================ .docname {Misaligned math in box} .include {template/template.qd} .bulletin issue:{193} pr:{195} nextrelease:{1.9.1} relatedto:{margin applied to `box-content :last-child`} .box {title} type:{note} $ A_{sub}^{super} $ $ A_{sub}^{super} $ ================================================ FILE: quarkdown-html/src/test/resources/issues/misaligned-math-in-quote.qd ================================================ .docname {Misaligned math in quote} .include {template/template.qd} .bulletin issue:{144} pr:{145} nextrelease:{1.7.0} relatedto:{margin applied to `blockquote :first-child` and `:last-child` in `latex` theme} > $ P_1 = P_0 \times \frac{S_1}{S_0} $ $ P_1 = P_0 \times \frac{S_1}{S_0} $ ================================================ FILE: quarkdown-html/src/test/resources/issues/multiple-font-configurations.qd ================================================ .docname {Multiple font configurations for different glyph sets} .include {template/template.qd} .bulletin issue:{215} pr:{226} nextrelease:{1.11.0} relatedto:{Font configurations are not stackable} .font {GoogleFonts:Ma Shan Zheng} heading:{GoogleFonts:ZCOOL QingKe HuangYou} .font {GoogleFonts:Karla} heading:{GoogleFonts:BBH Sans Hegarty} ## 混合文字示例 — Lorem Ipsum Test Lorem ipsum dolor sit amet,世界如梦,consectetur adipiscing elit,风起云涌,sed do eiusmod tempor incididunt ut labore et dolore magna aliqua。 .box {Box title 世界如梦} type:{note} Lorem ipsum dolor sit amet,世界如梦,consectetur adipiscing elit,风起云涌。 ```python def hello(): print("Hello, 世界如梦") ``` ================================================ FILE: quarkdown-html/src/test/resources/issues/template/template.qd ================================================ .var {mockdir} {../../../../../mock} .function {bulletin} issue? pr? nextrelease? relatedto?: # .docname .var {repo} {https://github.com/iamgio/quarkdown} - **Opened in:** .issue::ifpresent {@lambda .text {#.1} url:{.repo/issues/.1}} - **Fixed in:** .pr::ifpresent {@lambda .text {#.1} url:{.repo/pulls/.1}} (next release: .nextrelease) - **Related to:** .relatedto ================================================ FILE: quarkdown-html/src/test/resources/media/file.txt ================================================ Media ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/blockquote.html ================================================ <blockquote> <p> Foo bar </p> <p> Baz bim </p> </blockquote> --- <blockquote> <p> To be, or not to be. </p> <p> That is the question. </p> <p class="attribution"> William Shakespeare </p> </blockquote> --- <blockquote class="tip"> <p> Hi there! </p> <p class="attribution"> Someone </p> </blockquote> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/code.html ================================================ <pre> <code> Code </code> </pre> --- <pre> <code class="no-highlight"> Code </code> </pre> --- <pre> <code class="nohljsln"> Code </code> </pre> --- <pre> <code> class Point { ... } </code> </pre> --- <pre> <code class="language-java nohljsln"> class Point { ... } </code> </pre> --- <pre> <code class="language-html"> &lt;a href=&quot;#&quot;&gt; </code> </pre> --- <pre> <code class="language-java focus-lines" data-focus-start="1" data-focus-end="2"> class Point { ... } </code> </pre> --- <pre> <code class="language-java focus-lines" data-focus-start="2"> class Point { ... } </code> </pre> --- <pre> <code class="language-java focus-lines" data-focus-end="1"> class Point { ... } </code> </pre> --- <figure> <pre> <code class="language-java"> class Point { ... } </code> </pre> <figcaption class="caption-bottom"> A Java code example. </figcaption> </figure> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/footnote.html ================================================ <span class="footnote-definition" id="__footnote-label" data-footnote-index="0"> <sup class="footnote-label"> 1 </sup> <span> Foo bar </span> </span> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/heading.html ================================================ <h1> Foo bar </h1> --- <h2> Foo bar </h2> --- <h2 data-decorative=""> Foo bar </h2> --- <h3 id="my-id"> Foo bar </h3> --- <h3> <strong> Foo bar </strong> </h3> --- <h4> Foo <em> bar </em> </h4> --- <h1 id="foo-bar"> Foo bar </h1> --- <h1 id="custom-id"> Foo bar </h1> --- <h4 id="foobar"> Foo <em> bar </em> </h4> --- <h1 class="page-break" id="foo-bar"> Foo bar </h1> --- <h2 class="page-break" id="foo-bar"> Foo bar </h2> --- <h3 id="foo-bar"> Foo bar </h3> --- <h4 id="foo-bar"> Foo bar </h4> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/math.html ================================================ <formula data-block=""> some expression </formula> --- <formula data-block=""> \lim_{x\to\infty}x </formula> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/orderedlist.html ================================================ <ol> </ol> --- <ol> <li> <p> A1 </p> <hr /> <p> A2 </p> </li> <li> <p> B1 </p> <hr /> <p> B2 </p> </li> <li> <p> C1 </p> <hr /> <p> C2 </p> </li> <li class="focused"> <p> D1 </p> <hr /> <p> D2 </p> </li> <li class="task-list-item"> <input disabled="" type="checkbox" checked="" /> <div> <p> E1 </p> <hr /> <p> E2 </p> </div> </li> </ol> --- <ol start="12"> <li> <p> A1 </p> <hr /> <p> A2 </p> </li> <li> <p> B1 </p> <hr /> <p> B2 </p> </li> <li> <p> C1 </p> <hr /> <p> C2 </p> </li> <li class="focused"> <p> D1 </p> <hr /> <p> D2 </p> </li> <li class="task-list-item"> <input disabled="" type="checkbox" checked="" /> <div> <p> E1 </p> <hr /> <p> E2 </p> </div> </li> </ol> --- <ol> <li> A1 <hr /> A2 </li> <li> B1 <hr /> B2 </li> <li> C1 <hr /> C2 </li> <li class="focused"> D1 <hr /> D2 </li> <li class="task-list-item"> <input disabled="" type="checkbox" checked="" /> E1 <hr /> E2 </li> </ol> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/paragraph.html ================================================ <p> Foo bar </p> --- <p> Foo <br /> bar </p> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/table.html ================================================ <table> <thead> <tr> <th> A </th> <th> B </th> </tr> </thead> <tbody> <tr> <td> C </td> <td> D </td> </tr> <tr> <td> E </td> <td> F </td> </tr> </tbody> </table> --- <table> <thead> <tr> <th align="center"> A </th> <th align="right"> B </th> </tr> </thead> <tbody> <tr> <td align="center"> C </td> <td align="right"> D </td> </tr> <tr> <td align="center"> E </td> <td align="right"> <strong> F </strong> </td> </tr> </tbody> </table> --- <table> <thead> <tr> <th> A </th> <th> B </th> </tr> </thead> <tbody> <tr> <td> C </td> <td> D </td> </tr> <tr> <td> E </td> <td> F </td> </tr> </tbody> <caption class="caption-bottom"> Table 'caption'. </caption> </table> ================================================ FILE: quarkdown-html/src/test/resources/rendering/block/unorderedlist.html ================================================ <ul> </ul> --- <ul> <li> <p> A1 </p> <hr /> <p> A2 </p> </li> <li> <p> B1 </p> <hr /> <p> B2 </p> </li> <li> <p> C1 </p> <hr /> <p> C2 </p> </li> <li class="focused"> <p> D1 </p> <hr /> <p> D2 </p> </li> <li class="task-list-item"> <input disabled="" type="checkbox" checked="" /> <div> <p> E1 </p> <hr /> <p> E2 </p> </div> </li> </ul> --- <ul> <li> A1 <hr /> A2 </li> <li> B1 <hr /> B2 </li> <li> C1 <hr /> C2 </li> <li class="focused"> D1 <hr /> D2 </li> <li class="task-list-item"> <input disabled="" type="checkbox" checked="" /> E1 <hr /> E2 </li> </ul> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/codespan.html ================================================ <code> Foo bar </code> --- <code> &lt;a href=&quot;#&quot;&gt; </code> --- <span class="codespan-content"> <code> #FFFF00 </code> <span style="background-color: rgba(255, 255, 0, 1.0);" class="color-preview"> </span> </span> --- <code> #FFFF00 </code> --- <span class="codespan-content"> <code> Foo bar </code> </span> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/emphasis.html ================================================ <em> Foo bar </em> --- <em> <em> Foo bar </em> </em> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/image.html ================================================ <img src="/url" alt="" /> --- <img src="/url" alt="" title="Title" /> --- <img src="/url" alt="Foo bar" style="width: 150.0px; height: 100.0px;" /> --- <img src="/url" alt="Foo bar" title="Title" style="width: 3.2cm;" /> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/link.html ================================================ <a href="https://google.com"> Foo bar </a> --- <a href="/url"> <strong> Foo bar </strong> </a> --- <a href="url" title="Title"> Foo bar baz </a> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/math.html ================================================ <formula> some expression </formula> --- <formula> \lim_{x\to\infty}x </formula> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/reffootnote.html ================================================ <sup class="footnote-reference footnote-label" data-definition="__footnote-label"> <a href="#__footnote-label"> 1 </a> </sup> --- fallback ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/refimage.html ================================================ <img src="/url" alt="Foo" title="Title" /> --- <img src="/url" alt="label" title="Title" /> --- <img src="/url" alt="label" title="Title" style="width: 150.0px; height: 100.0px;" /> --- <em> fallback </em> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/reflink.html ================================================ <a href="/url" title="Title"> <strong> Foo </strong> </a> --- <a href="/url" title="Title"> label </a> --- <em> fallback </em> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/strikethrough.html ================================================ <del> Foo bar </del> --- <del> <strong> Foo bar </strong> </del> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/strong.html ================================================ <strong> Foo bar </strong> --- <strong> <strong> Foo bar </strong> </strong> ================================================ FILE: quarkdown-html/src/test/resources/rendering/inline/strongemphasis.html ================================================ <em> <strong> Foo bar </strong> </em> --- <em> <strong> <em> <strong> Foo bar </strong> </em> </strong> </em> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/bibliography.html ================================================ <h1 class="page-break" id="bibliography"> Bibliography </h1> <div class="bibliography bibliography-test"> <span class="bibliography-entry-label"> [1] </span> <span class="bibliography-entry-content"> Content of einstein. </span> <span class="bibliography-entry-label"> [2] </span> <span class="bibliography-entry-content"> Content of latexcompanion. </span> <span class="bibliography-entry-label"> [3] </span> <span class="bibliography-entry-content"> Content of knuthwebsite. </span> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/box.html ================================================ <div class="box callout"> <header> <h4> Title </h4> </header> <div class="box-content"> <p> Foo <br /> bar </p> </div> </div> --- <div class="box warning"> <header> <h4> Title <em> Title </em> </h4> </header> <div class="box-content"> <p> Foo <br /> bar </p> </div> </div> --- <div class="box error"> <div style="padding: 4.0cm;" class="box-content"> <p> Foo <br /> bar </p> </div> </div> --- <div class="box error" style="background-color: rgba(255, 0, 120, 1.0); color: rgba(0, 10, 25, 1.0);"> <header style="color: rgba(0, 10, 25, 1.0); padding: 3.0in;"> <h4> Title </h4> </header> <div style="padding: 3.0in;" class="box-content"> <p> Foo <br /> bar </p> </div> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/clipped.html ================================================ <div class="clip clip-circle"> <div class="container"> <p> Foo <br /> bar </p> </div> </div> --- <div class="clip clip-circle"> <div class="container"> <p> Foo <br /> bar </p> <p> Foo <br /> bar </p> </div> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/collapse.html ================================================ <details> <summary> <em> Hello </em> </summary> <strong> world </strong> </details> --- <details open=""> <summary> Hello </summary> <blockquote> <p> world </p> </blockquote> </details> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/container.html ================================================ <div class="container"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container" style="color: rgba(100, 20, 80, 1.0); background-color: rgba(10, 20, 30, 1.0);"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container" style="background-color: rgba(10, 20, 30, 1.0); padding: 2.0cm 3.0cm 2.0cm 3.0cm; border-radius: 12.0px 12.0px 12.0px 12.0px;"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container fullwidth" style="margin: 2.0cm 2.0cm 2.0cm 2.0cm; padding: 2.0in 3.0% 4.0in 5.0in; border-color: rgba(30, 20, 10, 1.0); border-width: 1.0cm 1.0cm 1.0cm 1.0cm; border-radius: 6.0px 6.0px 6.0px 6.0px; border-style: solid; justify-items: center; text-align: justify;"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container" style="border-color: rgba(30, 20, 10, 1.0); border-style: dotted; justify-items: end;"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container float custom-class" style="float: inline-end;"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> --- <div class="container size-large" style="font-weight: bold; font-style: italic; font-variant: small-caps; text-decoration: line-through; text-transform: uppercase;"> <p> Foo bar </p> <blockquote> <p> Baz </p> </blockquote> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/figure.html ================================================ <figure> <img src="/url" alt="" title="" /> <figcaption class="caption-bottom"> </figcaption> </figure> --- <figure> <img src="/url" alt="" title="Title" /> <figcaption class="caption-bottom"> Title </figcaption> </figure> --- <figure> <img src="/url" alt="" title="Title" style="width: 150.0px; height: 100.0px;" /> <figcaption class="caption-bottom"> Title </figcaption> </figure> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/filetree.html ================================================ <div class="file-tree"> <ul> <li class="file" data-name="file1.txt"> file1.txt </li> <li class="file" data-name="file2.json"> file2.json </li> </ul> </div> --- <div class="file-tree"> <ul> <li class="directory" data-name="src"> src <ul> <li class="file" data-name="main.ts"> main.ts </li> <li class="file" data-name="utils.ts"> utils.ts </li> </ul> </li> <li class="file" data-name="README.md"> README.md </li> </ul> </div> --- <div class="file-tree"> <ul> <li class="file" data-name="index.ts"> index.ts </li> <li class="ellipsis"> &hellip; </li> </ul> </div> --- <div class="file-tree"> <ul> <li class="file" data-name="file1.txt"> file1.txt </li> <li class="file" data-name="file2.txt" data-highlighted=""> file2.txt </li> <li class="directory" data-name="src" data-highlighted=""> src <ul> <li class="file" data-name="main.ts" data-highlighted=""> main.ts </li> <li class="file" data-name="utils.ts"> utils.ts </li> </ul> </li> <li class="ellipsis" data-highlighted=""> &hellip; </li> </ul> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/fullspan.html ================================================ <div class="container full-column-span"> <p> Foo <br /> bar </p> </div> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/inlinecollapse.html ================================================ <span class="inline-collapse" data-full-text="Foo bar" data-collapsed-text="Placeholder" data-collapsed="true"> Placeholder </span> --- <span class="inline-collapse" data-full-text="Foo bar" data-collapsed-text="Placeholder" data-collapsed="false"> Foo bar </span> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/lastheading.html ================================================ <span class="last-heading" data-depth="3"> </span> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/navigationcontainer.html ================================================ <nav> <p> Nav </p> </nav> --- <nav role="navigation" data-role="page-list"> <p> Nav </p> </nav> --- <nav role="table-of-contents" data-role="table-of-contents"> <p> Nav </p> </nav> ================================================ FILE: quarkdown-html/src/test/resources/rendering/quarkdown/texttransform.html ================================================ <span class="size-large" style="font-style: italic; text-decoration: line-through;"> Foo </span> --- <span class="size-tiny" style="font-weight: bold; font-variant: small-caps; text-decoration: underline overline;"> <em> Foo </em> bar </span> --- <span style="text-decoration: underline overline line-through; text-transform: capitalize; color: rgba(255, 0, 0, 1.0);"> Foo </span> --- <span> Foo </span> --- <span class="size-large custom-class"> Foo </span> --- <sub> 2 </sub> --- <sup style="font-weight: bold;"> 2 </sup> ================================================ FILE: quarkdown-html/tsconfig.json ================================================ { "include": ["src", "src/types"], "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "libReplacement": true, /* Enable lib replacement. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "commonjs", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ // "resolveJsonModule": true, /* Enable importing .json files. */ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outDir": "./", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "newLine": "crlf", /* Set the newline character for emitting files. */ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } } ================================================ FILE: quarkdown-html/vitest.config.ts ================================================ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { environment: 'happy-dom', include: ['src/**/__tests__/**/*.{test,spec}.ts'], globals: true, coverage: { reporter: ['text', 'html'], }, }, }); ================================================ FILE: quarkdown-interaction/README.md ================================================ # interaction This module provides a library to allow Quarkdown components to interact with third-party services and processes. ================================================ FILE: quarkdown-interaction/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation(kotlin("test")) implementation(project(":quarkdown-core")) } tasks.test { useJUnitPlatform() } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/Env.kt ================================================ package com.quarkdown.interaction /** * Environment variables that may affect the `interaction` module. */ object Env { /** * The prefix for NPM operations used by Quarkdown. */ const val QUARKDOWN_NPM_PREFIX = "QD_NPM_PREFIX" private const val NODE_PATH = "NODE_PATH" /** * Whether to disable the Chrome sandbox for PDF export. */ const val NO_SANDBOX = "QD_NO_SANDBOX" private operator fun get(key: String): String? = System.getenv(key) /** * The global prefix for NPM operations, if set. * This is determined by the `QD_NPM_PREFIX` environment variable. */ val npmPrefix: String? get() = this[QUARKDOWN_NPM_PREFIX] /** * The path to the Node.js modules, if set. Ideally, this should point to `${QD_NPM_PREFIX}/node_modules`. * This is determined by the `NODE_PATH` environment variable. */ val nodePath: String? get() = this[NODE_PATH] } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/ExecutableWrapper.kt ================================================ package com.quarkdown.interaction.executable import com.quarkdown.core.log.Log import java.io.File import kotlin.streams.asSequence /** * Wrapper for a third-party executable program. */ abstract class ExecutableWrapper { /** * Path to the executable program. */ abstract val path: String /** * @return whether the executable is found and works as expected */ abstract val isValid: Boolean /** * Working directory to run the executable in. * If `null`, the current working directory will be used. */ abstract val workingDirectory: File? /** * @throws IllegalStateException if the path is blank */ protected fun validate() { require(path.isNotBlank()) { "Path cannot be blank" } } /** * @param args arguments to pass to the executable * @param workingDirectory working directory to run the executable in. Defaults to [workingDirectory] * @return the process builder */ private fun createProcessBuilder( vararg args: String, workingDirectory: File? = this.workingDirectory, ): ProcessBuilder = ProcessBuilder(path, *args) .directory(workingDirectory) .redirectErrorStream(true) /** * @param args arguments to pass to the executable * @param workingDirectory working directory to run the executable in * @return the stdout and stderr of the execution */ protected fun launchAndGetOutput( vararg args: String, workingDirectory: File? = this.workingDirectory, ): String { val process = createProcessBuilder(*args, workingDirectory = workingDirectory).start() val output = process.inputStream .bufferedReader() .lines() .asSequence() .onEach(Log::debug) .joinToString(separator = "\n") process.waitFor() Log.debug("Command `$path ${args.joinToString()}` exited with code ${process.exitValue()}. Output:\n$output") if (process.exitValue() != 0) { throw IllegalStateException("Command failed with non-zero exit code:\n$output") } return output } } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/NodeJsWrapper.kt ================================================ package com.quarkdown.interaction.executable import java.io.File import java.io.Reader /** * Wrapper for launching scripts via Node.js. * @param path path to the Node.js executable */ data class NodeJsWrapper( override val path: String, override val workingDirectory: File, ) : ExecutableWrapper() { override val isValid: Boolean get() = try { eval("console.log('Hello!')").trim() == "Hello!" } catch (e: Exception) { false } init { validate() } /** * Runs an expression or code snippet via Node.js from [path]. * @param code the code to run * @param argv arguments to pass to the script * @return the stdout and stderr of the execution */ fun eval( code: String, vararg argv: String, ): String = launchAndGetOutput("-e", code, *argv) /** * @see eval */ fun eval( code: Reader, vararg argv: String, ): String = eval(code.readText(), *argv) /** * @return whether the given [module] is linked to the project located in [workingDirectory] */ @Deprecated("Not used anymore since v1.6.0") fun isLinked(module: NodeModule): Boolean = try { eval("require('${module.name}')").isEmpty() } catch (e: Exception) { false } companion object : WithDefaultPath { /** * Default path to the Node.js executable. */ private const val DEFAULT_PATH = "node" override val defaultPath: String get() = DEFAULT_PATH } } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/NodeModule.kt ================================================ package com.quarkdown.interaction.executable import com.quarkdown.interaction.Env /** * Abstraction of a Node.js module, which can be installed and linked to a [NodeJsWrapper] through a [NpmWrapper]. * @param name name of the module, which matches the name in the NPM registry */ open class NodeModule( val name: String, ) /** * Exception thrown when a required Node.js module is not installed. * @param module the module that is not installed */ class NodeModuleNotInstalledException( module: NodeModule, ) : IllegalStateException( """ Module '${module.name}' is not installed. Please install it via `npm install ${module.name} --prefix $${Env.QUARKDOWN_NPM_PREFIX}` and retry. Make sure ${Env.QUARKDOWN_NPM_PREFIX} is an environment variable pointing to Quarkdown's `lib` directory or any other installation directory, and it must be available at Quarkdown's launch. The current value is '${Env.npmPrefix}'. Note that '/node_modules' is automatically appended to this path, so if your system has modules installed in '/usr/lib/node_modules', use '/usr/lib'. For more information, see: https://quarkdown.com/wiki/pdf-export Note: installing Quarkdown via a package manager is suggested, as it sets up the required dependencies automatically. """.trimIndent(), ) ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/NodeNpmHelper.kt ================================================ package com.quarkdown.interaction.executable import java.io.File import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.deleteRecursively /** * Helper class for easily launching Node.js scripts with required NPM modules. * @param node the Node.js wrapper * @param npm the NPM wrapper */ class NodeNpmHelper( private val node: NodeJsWrapper, private val npm: NpmWrapper, ) { private fun checkWrapper( wrapper: ExecutableWrapper, name: String, ) { check(wrapper.isValid) { "$name executable cannot be found at '${wrapper.path}'" } } private fun checkModule(module: NodeModule) { if (!npm.isInstalled(node, module)) { throw NodeModuleNotInstalledException(module) } } @OptIn(ExperimentalPathApi::class) private fun cleanup() { // Path#deleteRecursively does not follow symlinks, while File#deleteRecursively does. // The symlinks contained in the node_modules directory point to the global packages, // and should not be deleted. sequenceOf("package.json", "package-lock.json", "node_modules") .map { File(node.workingDirectory, it).toPath() } .forEach { it.deleteRecursively() } } /** * Checks if the Node.js and NPM wrappers are present and valid, * links, and optionally installs, the required modules, * runs the given action and cleans up the working directory from generated files. * @param requiredModules the modules to link * @param action the action to run after the executables are checked and the modules are linked * @throws IllegalStateException if the Node.js or NPM wrappers are not valid * @throws NodeModuleNotInstalledException if a required module is not installed globally */ fun launch( vararg requiredModules: NodeModule, action: () -> Unit, ) { checkWrapper(node, "Node.js") checkWrapper(npm, "NPM") requiredModules.forEach(::checkModule) try { action() } catch (e: Exception) { throw e } finally { cleanup() } } } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/NpmWrapper.kt ================================================ package com.quarkdown.interaction.executable import com.quarkdown.interaction.Env import com.quarkdown.interaction.os.OsUtils import java.io.File /** * Wrapper for invoking the Node Package Manager, * with functionalities for installing and linking [NodeModule]s. * * The following environment variable can affect the behavior of this wrapper: * - [com.quarkdown.interaction.Env.QD_NPM_PREFIX]: if set, determines the global prefix for NPM operations. * - [com.quarkdown.interaction.Env.NODE_PATH]: if set, determines the path to the Node.js modules. * * @param path path to the NPM executable */ class NpmWrapper( override val path: String, ) : ExecutableWrapper() { override val workingDirectory: File? = null /** * If the `QD_NPM_PREFIX` environment variable is set, * returns the `--prefix` argument with its value. */ private val globalPrefixArgs: Array<String> get() = when (val prefix = Env.npmPrefix) { null -> emptyArray() else -> arrayOf("--prefix", prefix) } override val isValid: Boolean get() = try { launchAndGetOutput("--version").isNotBlank() } catch (_: Exception) { false } init { validate() } /** * @return whether the given [module] is installed in [node]'s working directory. */ fun isInstalled( node: NodeJsWrapper, module: NodeModule, ): Boolean = try { launchAndGetOutput( "list", module.name, *globalPrefixArgs, workingDirectory = node.workingDirectory, ).contains(module.name) } catch (_: Exception) { false } /** * Links a globally installed Node.js module to the project located in [node]'s working directory. * @param node the Node.js wrapper * @param module the module to link */ @Deprecated("Not used anymore since v1.6.0") fun link( node: NodeJsWrapper, module: NodeModule, ) { launchAndGetOutput("link", module.name, *globalPrefixArgs, workingDirectory = node.workingDirectory) } /** * Unlinks a linked Node.js module from the project located in [node]'s working directory. * @param node the Node.js wrapper * @param module the module to unlink */ @Deprecated("Not used anymore since v1.6.0") fun unlink( node: NodeJsWrapper, module: NodeModule, ) { launchAndGetOutput("unlink", module.name, *globalPrefixArgs, workingDirectory = node.workingDirectory) } companion object : WithDefaultPath { /** * Default base path to the NPM executable. */ private const val DEFAULT_PATH = "npm" /** * Default path to the NPM executable, OS-dependent. * @see [com.quarkdown.interaction.os.OsUtils.cmdBasedExecutablePath] */ private val osDependentDefaultPath: String get() = OsUtils.cmdBasedExecutablePath(DEFAULT_PATH) override val defaultPath: String get() = osDependentDefaultPath } } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/executable/WithDefaultPath.kt ================================================ package com.quarkdown.interaction.executable /** * Supplier of a default path of an executable. */ interface WithDefaultPath { /** * The default path to the executable, either statically defined or platform-dependent. */ val defaultPath: String } ================================================ FILE: quarkdown-interaction/src/main/kotlin/com/quarkdown/interaction/os/OsUtils.kt ================================================ package com.quarkdown.interaction.os object OsUtils { /** * The name of the current operating system. */ private val osName: String by lazy { System.getProperty("os.name").lowercase() } /** * @return whether the current operating system is Windows */ private fun isWindows(): Boolean = "win" in osName /** * @return whether the current operating system is Unix-like */ private fun isUnix(): Boolean = "nix" in osName || "nux" in osName || "mac" in osName /** * Runs the given [windows] or [unix] function depending on the current operating system. * @return the result of the function based on the OS */ fun <T> dependent( windows: () -> T, unix: () -> T, ) = when { isWindows() -> windows() isUnix() -> unix() else -> throw UnsupportedOperationException("Unexpected OS: $osName") } /** * @param basePath the base path of the executable * @return the path to the executable, with `.cmd` appended on Windows */ fun cmdBasedExecutablePath(basePath: String): String = dependent( windows = { "$basePath.cmd" }, unix = { basePath }, ) } ================================================ FILE: quarkdown-interaction/src/test/kotlin/com/quarkdown/interaction/NodeNpmWrapperTest.kt ================================================ package com.quarkdown.interaction import com.quarkdown.interaction.executable.NodeJsWrapper import com.quarkdown.interaction.executable.NodeModule import com.quarkdown.interaction.executable.NpmWrapper import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse private fun npm() = NpmWrapper(NpmWrapper.defaultPath) private fun node(workingDirectory: File) = NodeJsWrapper(NodeJsWrapper.defaultPath, workingDirectory) /** * Tests for wrappers around Node.js and NPM. * @see NodeJsWrapper * @see NpmWrapper */ class NodeNpmWrapperTest { private data object PuppeteerNodeModule : NodeModule("puppeteer") private val directory: File = createTempDirectory() .toFile() @BeforeTest fun setup() { directory.deleteRecursively() directory.mkdirs() } @Test fun `nodejs wrapper`() { val node = node(workingDirectory = directory) assumeTrue(node.isValid) assertEquals("Hello, Quarkdown!", node.eval("console.log('Hello, Quarkdown!')")) assertEquals( "Hello, Quarkdown!\nHello, Quarkdown!", node.eval( """ function hello() { console.log('Hello, Quarkdown!'); } hello(); hello(); """.trimIndent(), ), ) } @Test fun `nonexisting nodejs`() { val node = NodeJsWrapper("quarkdown-nodejs-nonexisting-path", directory) assertEquals(false, node.isValid) } @Test fun `npm wrapper`() { assumeTrue(npm().isValid) } @Test fun `nonexisting npm`() { val npm = NpmWrapper("quarkdown-npm-nonexisting-path") assertEquals(false, npm.isValid) } @Test fun `nonexisting module not installed`() { val node = node(workingDirectory = directory) val npm = npm() val module = NodeModule("quarkdown-nonexisting-module-xyz") assumeTrue(npm.isValid) assertFalse(npm.isInstalled(node, module)) } } ================================================ FILE: quarkdown-libs/README.md ================================================ # libs This module contains libraries written in Quarkdown ([main/resources](src/main/resources)). The Gradle build system will automatically copy the `.qd` files into the `lib/qd` directory of the distribution zip. At runtime, the CLI option `-l` or `--libs` sets the path to the library directory to use, which clearly defaults to `lib/qd`. In order to load a library from the library directory, the `.include {name}` function can be used. After the library is loaded, the functions defined in the library can be used in the main document. See [wiki: *Importing external libraries*](https://quarkdown.com/wiki/importing-external-libraries) for further information. ================================================ FILE: quarkdown-libs/build.gradle.kts ================================================ plugins { kotlin("jvm") } ================================================ FILE: quarkdown-libs/src/main/resources/docs.qd ================================================ .doctype {docs} .var {pagelistposition} {lefttop} .var {tocposition} {righttop} .include {.pathtoroot/_setup.qd} .pagemargin {.pagelistposition} .navigation role:{pagelist} .include {.pathtoroot/_nav.qd} .pagemargin {.tocposition} .tableofcontents #! .docname ================================================ FILE: quarkdown-libs/src/main/resources/paper.qd ================================================ .localization {paper} - English - abstract: Abstract - definition: Definition - lemma: Lemma - theorem: Theorem - proof: Proof - Italian - abstract: Sommario - definition: Definizione - lemma: Lemma - theorem: Teorema - proof: Dimostrazione - German - abstract: Abstract - definition: Definition - lemma: Lemma - theorem: Theorem - proof: Beweis - Japanese - abstract: 概要 - definition: 定義 - lemma: 補題 - theorem: 定理 - proof: 証明 - Chinese - abstract: 摘要 - definition: 定义 - lemma: 引理 - theorem: 定理 - proof: 证明 - French - abstract: Résumé - definition: Définition - lemma: Lemme - theorem: Théorème - proof: Preuve <!-- Alignment of the 'Abstract' title, relative to its body content --> .var {abstractalignment} {center} <!-- The suffix that follows the title of a block, e.g. "Definition", "Lemma", ... --> .var {paperblocksuffix} {\.} <!-- Content at the end of a proof block --> .var {proofend} {∎} <!-- An 'abstract' block --> .function {abstract} content: .container padding:{0 1cm} fullwidth:{yes} .align {.abstractalignment} ####! .localize {paper:abstract} .container padding:{2mm 0} .content .whitespace <!-- Internal helper that builds a titled numerable block --> .function {namedparagraph} name tag? content: .numbered {.tag::otherwise {}} number: **.concatenate { .name } with:{ .string {" .number"} } if:{ .isnotempty {.number} }.paperblocksuffix** .content .function {INTERNALtypedparagraph} type content: .var {localizedname} {.localize {paper:.type}} .var {numberingtag} {.concatenate {.type} {s}} .namedparagraph {.localizedname} {.numberingtag} {.content} <!-- A numerable 'definition' block --> .function {definition} content: .INTERNALtypedparagraph {definition} {.content} <!-- A numerable 'lemma' block --> .function {lemma} content: .INTERNALtypedparagraph {lemma} {.content} <!-- A numerable 'theorem' block --> .function {theorem} content: .INTERNALtypedparagraph {theorem} {.content} <!-- A numerable 'proof' block --> .function {proof} content: .INTERNALtypedparagraph {proof} {.content} .align {end} .text {.proofend} size:{huge} ================================================ FILE: quarkdown-lsp/LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2025 Giorgio Garofalo (iamgio) <https://github.com/iamgio/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) <year> <name of author> This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>. ================================================ FILE: quarkdown-lsp/README.md ================================================ # lsp This module contains sources for the Quarkdown language server, which follows the [Language Server Protocol (LSP)](https://microsoft.github.io/language-server-protocol/), allowing integration with code editors and IDEs: - [Quarkdown for VS Code](https://github.com/quarkdown-labs/quarkdown-vscode) Features include: - Syntax highlighting for function calls via semantic tokens - Completions for functions, parameters and values - Documentation on hover - Diagnostics The implementation is based on the [LSP4J](https://github.com/eclipse-lsp4j/lsp4j) library. ================================================ FILE: quarkdown-lsp/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:1.0.0") implementation("com.github.h0tk3y.betterParse:better-parse:0.4.4") implementation("com.vladsch.flexmark:flexmark-html2md-converter:0.64.8") implementation(project(":quarkdown-core")) implementation(project(":quarkdown-quarkdoc-reader")) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/QuarkdownLanguageServer.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.cache.CacheableFunctionCatalogue import com.quarkdown.lsp.completion.CompletionSuppliersFactory import com.quarkdown.lsp.diagnostics.DiagnosticsSuppliersFactory import com.quarkdown.lsp.highlight.SemanticTokensSuppliersFactory import com.quarkdown.lsp.highlight.TokenType import com.quarkdown.lsp.hover.HoverSuppliersFactory import com.quarkdown.lsp.ontype.OnTypeFormattingSuppliersFactory import com.quarkdown.lsp.pattern.QuarkdownPatterns import org.eclipse.lsp4j.CompletionOptions import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DocumentOnTypeFormattingOptions import org.eclipse.lsp4j.InitializeParams import org.eclipse.lsp4j.InitializeResult import org.eclipse.lsp4j.MessageParams import org.eclipse.lsp4j.MessageType import org.eclipse.lsp4j.PublishDiagnosticsParams import org.eclipse.lsp4j.SemanticTokensLegend import org.eclipse.lsp4j.SemanticTokensWithRegistrationOptions import org.eclipse.lsp4j.ServerCapabilities import org.eclipse.lsp4j.TextDocumentSyncKind import org.eclipse.lsp4j.jsonrpc.messages.Either import org.eclipse.lsp4j.services.LanguageClient import org.eclipse.lsp4j.services.LanguageClientAware import org.eclipse.lsp4j.services.LanguageServer import org.eclipse.lsp4j.services.TextDocumentService import org.eclipse.lsp4j.services.WorkspaceService import java.io.File import java.util.concurrent.CompletableFuture import kotlin.concurrent.thread import kotlin.system.exitProcess /** * Quarkdown Language Server implementation. * @param quarkdownDirectory the directory containing the Quarkdown distribution, if available */ class QuarkdownLanguageServer( private val quarkdownDirectory: File?, ) : LanguageServer, LanguageClientAware { private val textDocumentService: TextDocumentService = QuarkdownTextDocumentService( this, CompletionSuppliersFactory.default(this), SemanticTokensSuppliersFactory.default(), HoverSuppliersFactory.default(this), DiagnosticsSuppliersFactory.default(this), OnTypeFormattingSuppliersFactory.default(), ) private val completionTriggers = with(QuarkdownPatterns.FunctionCall) { listOf( BEGIN, CHAIN_SEPARATOR.last().toString(), ARGUMENT_BEGIN, ) } private val onTypeFormattingOptions = DocumentOnTypeFormattingOptions("\n") private val workspaceService: WorkspaceService = QuarkdownWorkspaceService(this) private lateinit var client: LanguageClient /** * The directory containing the documentation files, if available. * This is located in the Quarkdown distribution. */ val docsDirectory: File? get() = quarkdownDirectory?.resolve("docs")?.takeIf { it.isDirectory } /** * @return the documentation directory, or throws an exception if it's not available * @throws IllegalStateException if the documentation directory does not exist */ fun docsDirectoryOrThrow(): File = requireNotNull(docsDirectory) { "Documentation directory is not available" } override fun initialize(params: InitializeParams?): CompletableFuture<InitializeResult?>? { val legend = SemanticTokensLegend( TokenType.legend, emptyList(), ) val serverCaps = ServerCapabilities().apply { textDocumentSync = Either.forLeft(TextDocumentSyncKind.Full) completionProvider = CompletionOptions(true, completionTriggers) hoverProvider = Either.forLeft(true) semanticTokensProvider = SemanticTokensWithRegistrationOptions(legend, true, null) documentOnTypeFormattingProvider = onTypeFormattingOptions } val response = InitializeResult(serverCaps) // Caching the available function catalogue for improved performance. thread { docsDirectory?.let(CacheableFunctionCatalogue::storeCatalogue) } return CompletableFuture.completedFuture(response) } override fun shutdown(): CompletableFuture<in Any>? = CompletableFuture.completedFuture(null) override fun exit() = exitProcess(0) override fun getTextDocumentService() = textDocumentService override fun getWorkspaceService() = workspaceService override fun connect(client: LanguageClient?) { this.client = client ?: throw IllegalStateException("Language client cannot be null") } /** * Publishes diagnostics to the client. * @param uri the document URI * @param diagnostics the list of diagnostics */ fun publishDiagnostics( uri: String, diagnostics: List<Diagnostic>, ) { client.publishDiagnostics(PublishDiagnosticsParams(uri, diagnostics)) } /** * Logs a message to the client. * @param message the message to log */ fun log(message: String) { client.logMessage(MessageParams(MessageType.Log, message)) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/QuarkdownLanguageServerLauncher.kt ================================================ package com.quarkdown.lsp import org.eclipse.lsp4j.jsonrpc.Launcher import org.eclipse.lsp4j.services.LanguageClient import java.io.File /** * Launcher for the Quarkdown Language Server. * @param quarkdownDirectory the directory containing the Quarkdown distribution, if available */ class QuarkdownLanguageServerLauncher( quarkdownDirectory: File?, ) { private val languageServer = QuarkdownLanguageServer(quarkdownDirectory) private val launcher by lazy { Launcher .Builder<LanguageClient>() .setLocalService(languageServer) .setRemoteInterface(LanguageClient::class.java) .setInput(System.`in`) .setOutput(System.out) .create() .let(::requireNotNull) } fun startListening() { val client: LanguageClient = requireNotNull(launcher.remoteProxy) languageServer.connect(client) launcher.startListening() } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/QuarkdownTextDocumentService.kt ================================================ package com.quarkdown.lsp import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.lsp.completion.CompletionSupplier import com.quarkdown.lsp.diagnostics.DiagnosticsSupplier import com.quarkdown.lsp.highlight.SemanticTokensSupplier import com.quarkdown.lsp.hover.HoverSupplier import com.quarkdown.lsp.ontype.OnTypeFormattingEditSupplier import com.quarkdown.lsp.subservices.CompletionSubservice import com.quarkdown.lsp.subservices.DiagnosticsSubservice import com.quarkdown.lsp.subservices.HoverSubservice import com.quarkdown.lsp.subservices.OnTypeFormattingSubservice import com.quarkdown.lsp.subservices.SemanticTokensSubservice import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionList import org.eclipse.lsp4j.CompletionParams import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DidChangeTextDocumentParams import org.eclipse.lsp4j.DidCloseTextDocumentParams import org.eclipse.lsp4j.DidOpenTextDocumentParams import org.eclipse.lsp4j.DidSaveTextDocumentParams import org.eclipse.lsp4j.DocumentOnTypeFormattingParams import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams import org.eclipse.lsp4j.SemanticTokens import org.eclipse.lsp4j.SemanticTokensParams import org.eclipse.lsp4j.TextDocumentIdentifier import org.eclipse.lsp4j.TextEdit import org.eclipse.lsp4j.jsonrpc.messages.Either import org.eclipse.lsp4j.services.TextDocumentService import java.util.concurrent.CompletableFuture import kotlin.concurrent.thread private typealias CompletionResult = CompletableFuture<Either<List<CompletionItem>, CompletionList>> /** * Service for handling text document operations in the Quarkdown Language Server. */ class QuarkdownTextDocumentService( private val server: QuarkdownLanguageServer, completionSuppliers: List<CompletionSupplier>, tokensSuppliers: List<SemanticTokensSupplier>, hoverSuppliers: List<HoverSupplier>, diagnosticsSuppliers: List<DiagnosticsSupplier>, formattingSuppliers: List<OnTypeFormattingEditSupplier>, ) : TextDocumentService { private val completionService = CompletionSubservice(completionSuppliers) private val semanticTokensService = SemanticTokensSubservice(tokensSuppliers) private val hoverService = HoverSubservice(hoverSuppliers) private val diagnosticsService = DiagnosticsSubservice(diagnosticsSuppliers) private val onTypeFormattingService = OnTypeFormattingSubservice(formattingSuppliers) /** * Maps document URIs to their text content. */ private val documents = mutableMapOf<String, TextDocument>() /** * Adds or updates a document in the internal URI association map, * and triggers async diagnostics processing. * @param uri the URI of the document * @param text the text content of the document * @param invalidateCache whether to invalidate any cached data associated with the document */ private fun putDocument( uri: String, text: CharSequence, invalidateCache: Boolean = false, ) { // Line endings are normalized to LF to ensure consistency. val text = text.normalizeLineSeparators().toString() val current: TextDocument? = documents[uri] val new: TextDocument = current ?.copy(text = text, cache = if (invalidateCache) null else current.cache) ?: TextDocument( text = text, setActive = { documents[uri] = this }, ) documents[uri] = new thread { processDiagnostics(uri, new) } } /** * Processes diagnostics (warning, errors, etc.) for the given document and publishes them to the client. * @param uri the URI of the document * @param document the document to process */ private fun processDiagnostics( uri: String, document: TextDocument, ) { val diagnostics: List<Diagnostic> = diagnosticsService.process(params = null, document) server.publishDiagnostics(uri, diagnostics) } /** * @return the document associated with the given identifier */ private fun getDocument(document: TextDocumentIdentifier): TextDocument = documents[document.uri] ?: throw IllegalArgumentException("No document found for URI: ${document.uri}") override fun didOpen(didOpenTextDocumentParams: DidOpenTextDocumentParams) { server.log( "Operation 'text/didOpen'" + "' {fileUri: '" + didOpenTextDocumentParams.textDocument.uri + "'} opened", ) putDocument(didOpenTextDocumentParams.textDocument.uri, didOpenTextDocumentParams.textDocument.text) } override fun didChange(didChangeTextDocumentParams: DidChangeTextDocumentParams) { server.log("Operation 'text/didChange'") putDocument( didChangeTextDocumentParams.textDocument.uri, didChangeTextDocumentParams.contentChanges .firstOrNull() ?.text ?.normalizeLineSeparators() ?: "", invalidateCache = true, ) } override fun didClose(didCloseTextDocumentParams: DidCloseTextDocumentParams) { server.log("Operation 'text/didClose'") documents.remove(didCloseTextDocumentParams.textDocument.uri) } override fun didSave(didSaveTextDocumentParams: DidSaveTextDocumentParams) { server.log("Operation 'text/didSave'") } override fun completion(params: CompletionParams): CompletionResult { val document = getDocument(params.textDocument) return CompletableFuture.supplyAsync { server.log("Operation 'text/completion'") Either.forLeft(completionService.process(params, document)) } } override fun resolveCompletionItem(unresolved: CompletionItem): CompletableFuture<CompletionItem> = CompletableFuture.completedFuture(unresolved) override fun semanticTokensFull(params: SemanticTokensParams): CompletableFuture<SemanticTokens> { server.log("Operation 'text/semanticTokens/full'") val document = getDocument(params.textDocument) return CompletableFuture.completedFuture(semanticTokensService.process(params, document)) } override fun hover(params: HoverParams): CompletableFuture<Hover?>? { server.log("Operation 'text/hover'") val document = getDocument(params.textDocument) return CompletableFuture.completedFuture(hoverService.process(params, document)) } override fun onTypeFormatting(params: DocumentOnTypeFormattingParams): CompletableFuture<List<TextEdit?>?>? { server.log("Operation 'text/onTypeFormatting'") val document = getDocument(params.textDocument) return CompletableFuture.completedFuture(onTypeFormattingService.process(params, document)) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/QuarkdownWorkspaceService.kt ================================================ package com.quarkdown.lsp import org.eclipse.lsp4j.DidChangeConfigurationParams import org.eclipse.lsp4j.DidChangeWatchedFilesParams import org.eclipse.lsp4j.services.WorkspaceService /** * */ class QuarkdownWorkspaceService( private val server: QuarkdownLanguageServer, ) : WorkspaceService { override fun didChangeConfiguration(params: DidChangeConfigurationParams?) { server.log("Configuration changed: ${params?.settings}") } override fun didChangeWatchedFiles(params: DidChangeWatchedFilesParams?) { server.log("Watched files changed: ${params?.changes?.joinToString { it.uri }}") } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/TextDocument.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.cache.DocumentCache /** * An immutable representation of a text document that the user is working on. * @param text the content of the document * @param cache precomputed cache for various attributes of the document, or `null` if missing * @param setActive function to overwrite the active document with this instance for the same source URI */ data class TextDocument( val text: String, val cache: DocumentCache? = null, val setActive: TextDocument.() -> Unit = {}, ) { /** * The pre-computed cache for the document if it exists; otherwise, computes and updates it * invoking [setActive]. */ val cacheOrCompute: DocumentCache get() { val cache = this.cache ?: DocumentCache.compute(this) this.updateCache(cache).setActive() return cache } /** * @return a copy of this document with the provided new cache. */ fun updateCache(newCache: DocumentCache): TextDocument = this.copy(cache = newCache) /** * @return a copy of this document with the cache invalidated (set to `null`). */ fun invalidateCache(): TextDocument = this.copy(cache = null) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/cache/CacheableFunctionCatalogue.kt ================================================ package com.quarkdown.lsp.cache import com.quarkdown.lsp.documentation.extractContentAsMarkup import com.quarkdown.quarkdoc.reader.dokka.DokkaHtmlWalker import java.io.File import java.util.concurrent.ConcurrentHashMap private typealias DocsDirectory = File /** * A cache for functions extracted from the Quarkdown documentation. * This cache is used to avoid walking and parsing the documentation files, * improving performance throughout the language server. */ object CacheableFunctionCatalogue { private val catalogue = ConcurrentHashMap<DocsDirectory, Set<DocumentedFunction>>() /** * Stores the functions extracted from the documentation files in the cache, * overwriting any existing entries for the given directory. * Results are not stored if no functions are found. * @param docsDirectory the directory containing the documentation files */ fun storeCatalogue(docsDirectory: DocsDirectory) { val functions = DokkaHtmlWalker(docsDirectory) .walk() .filter { it.isInModule } .mapNotNull { val extractor = it.extractor() DocumentedFunction( data = extractor.extractFunctionData() ?: return@mapNotNull null, rawData = it, documentationAsMarkup = extractor.extractContentAsMarkup(), ) }.toSet() if (functions.isNotEmpty()) { catalogue[docsDirectory] = functions } } /** * Retrieves the functions from the cache for the given documentation directory. * If the cache is empty, it attempts to store the catalogue first. * If no functions are found again, an empty sequence is returned. * @param docsDirectory the directory containing the documentation files * @return a sequence of documented functions */ fun getCatalogue(docsDirectory: DocsDirectory): Sequence<DocumentedFunction> = this.catalogue[docsDirectory]?.asSequence() ?: storeCatalogue(docsDirectory).let { this.catalogue[docsDirectory] }?.asSequence() ?: emptySequence() /** * Searches for functions whose names start with the given query string, case-insensitively. * @param docsDirectory the directory containing the documentation files * @param nameQuery the query string to search for * @return a sequence of documented functions matching the query */ fun searchAll( docsDirectory: DocsDirectory, nameQuery: String, ): Sequence<DocumentedFunction> = getCatalogue(docsDirectory) .filter { it.data.name.startsWith(nameQuery, ignoreCase = true) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/cache/DocumentCache.kt ================================================ package com.quarkdown.lsp.cache import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallTokenizer /** * Cache for various precomputed attributes of a [TextDocument]. * * This allows avoiding repeated computation of expensive operations like tokenization * when the document content hasn't changed. * * @param functionCalls the list of function calls identified and tokenized in the document */ data class DocumentCache( val functionCalls: List<FunctionCall>, ) { companion object { /** * Computes a [DocumentCache] for the given [document] by tokenizing its content. * @param document the text document to compute the cache for * @return a new [DocumentCache] instance with computed attributes */ fun compute(document: TextDocument): DocumentCache { val functionCalls = FunctionCallTokenizer().getFunctionCalls(document.text) return DocumentCache( functionCalls = functionCalls, ) } } } /** * The list of function calls from a [TextDocument]'s cache, * computing and updating the cache if necessary. */ val TextDocument.functionCalls: List<FunctionCall> get() = this.cacheOrCompute.functionCalls ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/cache/DocumentedFunction.kt ================================================ package com.quarkdown.lsp.cache import com.quarkdown.quarkdoc.reader.DocsFunction import com.quarkdown.quarkdoc.reader.DocsWalker import org.eclipse.lsp4j.MarkupContent /** * Cached information extracted from the Quarkdown documentation about a function. * @param data the processed function data * @param rawData the raw data from the documentation walker * @param documentationAsMarkup the documentation content as markup, if available, supported by the LSP */ data class DocumentedFunction( val data: DocsFunction, val rawData: DocsWalker.Result<*>, val documentationAsMarkup: MarkupContent?, ) { /** * The name of the function. */ val name: String get() = data.name } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/CompletionItemConverters.kt ================================================ package com.quarkdown.lsp.completion import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.completion.function.FunctionCallInsertionSnippet import com.quarkdown.lsp.documentation.htmlToMarkup import com.quarkdown.quarkdoc.reader.DocsParameter import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionItemKind import org.eclipse.lsp4j.InsertTextFormat import org.eclipse.lsp4j.jsonrpc.messages.Either // Converters from various types to LSP completion items. /** * Converts a [DocumentedFunction] to a [CompletionItem] for use in function name completion. * @param function the documented function to convert * @param chained whether the function is chained call, hence the first parameter should not be included in the snippet */ fun DocumentedFunction.toCompletionItem(chained: Boolean) = CompletionItem().apply { label = name detail = rawData.moduleName documentation = Either.forRight(documentationAsMarkup) kind = CompletionItemKind.Function insertTextFormat = InsertTextFormat.Snippet insertText = FunctionCallInsertionSnippet.forFunction(this@toCompletionItem.data, chained) } /** * Converts a [DocsParameter] to a [CompletionItem] for use in parameter name completion. */ fun DocsParameter.toCompletionItem() = CompletionItem().apply { label = name detail = if (!isOptional) "required" else null documentation = Either.forRight(description.htmlToMarkup()) kind = CompletionItemKind.Field insertTextFormat = InsertTextFormat.Snippet insertText = FunctionCallInsertionSnippet.forParameter(this@toCompletionItem, alwaysNamed = true) } /** * Converts a generic string value, such as an allowed value for a parameter, to a [CompletionItem]. */ fun String.toCompletionItem() = CompletionItem().apply { label = this@toCompletionItem kind = CompletionItemKind.Value insertTextFormat = InsertTextFormat.Snippet } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/CompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion import com.quarkdown.lsp.TextDocument import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams /** * Interface for providing completion items based on the current context in a text document. * * Implementations of this interface should provide logic to generate a list of completion items * based on the provided parameters and the current text content. */ interface CompletionSupplier { /** * Generates a list of completion items. * @param params the parameters for the completion request, including the position in the document * @param document the current document * @return a list of completion items that can be suggested */ fun getCompletionItems( params: CompletionParams, document: TextDocument, ): List<CompletionItem> } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/CompletionSuppliersFactory.kt ================================================ package com.quarkdown.lsp.completion import com.quarkdown.lsp.QuarkdownLanguageServer import com.quarkdown.lsp.completion.function.name.FunctionNameCompletionSupplier import com.quarkdown.lsp.completion.function.parameter.FunctionParameterAllowedValuesCompletionSupplier import com.quarkdown.lsp.completion.function.parameter.FunctionParameterNameCompletionSupplier import java.io.File /** * Factory for creating a list of [CompletionSupplier]s. */ object CompletionSuppliersFactory { /** * The default completion suppliers cover completions for: * - Function name ([FunctionNameCompletionSupplier]), both regular and chained * - Function parameter name ([FunctionParameterNameCompletionSupplier]) * - Function parameter values ([FunctionParameterAllowedValuesCompletionSupplier]) * * @param server the Quarkdown language server instance * @return the default list of [CompletionSupplier] instances */ fun default(server: QuarkdownLanguageServer): List<CompletionSupplier> = this.functions(docsDirectory = server.docsDirectoryOrThrow()) /** * @param docsDirectory the directory containing the documentation files * @return the [CompletionSupplier]s that handle function call completions */ internal fun functions(docsDirectory: File): List<CompletionSupplier> = listOf( FunctionNameCompletionSupplier(docsDirectory), FunctionParameterAllowedValuesCompletionSupplier(docsDirectory), FunctionParameterNameCompletionSupplier(docsDirectory), ) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/AbstractFunctionCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.cache.functionCalls import com.quarkdown.lsp.completion.CompletionSupplier import com.quarkdown.lsp.documentation.getDocumentation import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.getAtSourceIndex import com.quarkdown.lsp.util.toOffset import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams import java.io.File /** * Provides completion items for function calls by scanning documentation files. * @param docsDirectory the directory containing the documentation files to extract function data from * @see com.quarkdown.lsp.completion.function.impl.parameter * @see com.quarkdown.lsp.completion.function.impl.name */ abstract class AbstractFunctionCompletionSupplier( protected val docsDirectory: File, ) : CompletionSupplier { /** * Generates completion items based on the provided function data and call context. * @param call the parsed function call * @param function the documentation data for the function being called * @param cursorIndex the index of the cursor in the source text * @param originalCursorIndex the original index of the cursor in the source text before any transformations via [transformIndex] */ protected abstract fun getCompletionItems( call: FunctionCall, function: DocumentedFunction?, cursorIndex: Int, originalCursorIndex: Int, ): List<CompletionItem> /** * Transforms the cursor index to a suitable index for processing. * This can be overridden to adjust how the cursor index is interpreted. * @param cursorIndex the original cursor index in the text * @param text the text being processed * @return the transformed cursor index, or null if no valid index can be determined */ protected open fun transformIndex( cursorIndex: Int, text: String, ): Int = cursorIndex override fun getCompletionItems( params: CompletionParams, document: TextDocument, ): List<CompletionItem> { val text = document.text // The index of the cursor in the source text. val index = params.position.toOffset(text) val transformedIndex = transformIndex(index, text).takeIf { it >= 0 } ?: return emptyList() val call: FunctionCall = document.functionCalls .getAtSourceIndex(transformedIndex) ?: return emptyList() // Looking up the function data from the documentation to extract available parameters to complete. val function: DocumentedFunction? = call.getDocumentation(docsDirectory) return getCompletionItems(call, function, transformedIndex, index) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/FunctionCallInsertionSnippet.kt ================================================ package com.quarkdown.lsp.completion.function import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.quarkdoc.reader.DocsFunction import com.quarkdown.quarkdoc.reader.DocsParameter // Constants for the LSP snippet format. private const val INSERTION_START = "\${" private const val INSERTION_DELIMITER = ":" private const val INSERTION_END = "}" /** * Suffix after each inline argument. */ private const val INLINE_ARGUMENT_SUFFIX = " " /** * If the inserted function is a chained call, the parameter with this index is ignored in the snippet. */ private const val SKIPPED_PARAMETER_INDEX_IN_CHAINED_CALL = 0 /** * Provider of function call snippets for completion, supported by the LSP. * @param function the function to generate the snippet from */ object FunctionCallInsertionSnippet { /** * Filters the parameters of a function to include only: * - Those that are non-optional (except for the body argument) * - The first parameter in a chained call */ private fun filterParameters( parameters: List<DocsParameter>, chained: Boolean, ): List<DocsParameter> = parameters.filterIndexed { index, param -> if (index == SKIPPED_PARAMETER_INDEX_IN_CHAINED_CALL && chained) { // Skips the first parameter if it's a chaining separator. return@filterIndexed false } !param.isOptional || param.isLikelyBody } /** * Generates a snippet for the function call in the accepted format by the LSP. * The snippet includes the function name and its non-optional parameters. * @param function the function to generate the snippet from * @return the function call snippet */ fun forFunction( function: DocsFunction, chained: Boolean, ): String { val params = filterParameters(function.parameters, chained) var insertionIndex = 1 return buildString { append(function.name) append(" ") params.forEachIndexed { index, param -> // If the parameter is a body parameter, but at least one inline parameter is optional, // an additional insertion point is added to improve the user experience, // allowing the user to continue typing inline parameters and then switch to the body parameter. if (param.isLikelyBody && index != function.parameters.lastIndex) { append(INSERTION_START) append(insertionIndex) append(INSERTION_DELIMITER) append(INSERTION_END) insertionIndex++ } append( forParameter( param, alwaysNamed = false, insertionIndex = insertionIndex, insertionPlaceholder = param.name, ), ) insertionIndex++ } } } /** * Generates a snippet for a function parameter in the accepted format by the LSP. * The snippet handles three cases: * - Inline, likely unnamed * - Inline, likely named * - Body * * @param parameter the parameter to generate the snippet from * @param alwaysNamed whether the parameter should always be treated as named if inline * @param insertionIndex the index of the insertion point in the snippet * @param insertionPlaceholder the placeholder text to insert at the insertion point * @return the function parameter snippet */ fun forParameter( parameter: DocsParameter, alwaysNamed: Boolean, insertionIndex: Int = 1, insertionPlaceholder: String = "", ): String = with(QuarkdownPatterns.FunctionCall) { // If the parameter has fixed values, generates a snippet with a|b|c as the placeholder. val valuesPlaceholder: String? = parameter.allowedValues?.joinToString(separator = "|") // The actual insertion. // If there are fixed values, the result will be `a|b|c` if the placeholder is empty, // or `placeholder (a|b|c)` otherwise. val insertion = buildString { append(INSERTION_START) append(insertionIndex) append(INSERTION_DELIMITER) append(insertionPlaceholder) valuesPlaceholder?.let { if (insertionPlaceholder.isEmpty()) { append(it) } else { append(" (") append(it) append(")") } } append(INSERTION_END) } // {abc} val inlineArgument = ARGUMENT_BEGIN + insertion + ARGUMENT_END + INLINE_ARGUMENT_SUFFIX when { parameter.isLikelyBody -> "\n" + CONVENTIONAL_BODY_INDENT + insertion // name:{abc} alwaysNamed || parameter.isLikelyNamed -> parameter.name + NAMED_ARGUMENT_DELIMITER + inlineArgument else -> inlineArgument } } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/name/ChainedFunctionNameCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function.name import com.quarkdown.lsp.cache.CacheableFunctionCatalogue import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.completion.function.AbstractFunctionCompletionSupplier import com.quarkdown.lsp.completion.toCompletionItem import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.tokenizer.findMatchingTokenBeforeIndex import com.quarkdown.lsp.tokenizer.getTokenAtSourceIndex import com.quarkdown.lsp.util.remainderUntilIndex import org.eclipse.lsp4j.CompletionItem import java.io.File /** * Provides completion items for chained function names in function calls by scanning documentation files. * * Let `|` be the cursor position in the text, this supplier provides completions for: * - `.function::|` * - `.function::func|` * * This supplier is proxied by [FunctionNameCompletionSupplier]. * @param docsDirectory the directory containing the documentation files to extract function data from */ class ChainedFunctionNameCompletionSupplier( docsDirectory: File, ) : AbstractFunctionCompletionSupplier(docsDirectory) { /** * Transforms the cursor index to force it to be part of the function call. * so that the returned index is always part of the function call. * * Why: * If the cursor is right after the chain separator (`::`), it means * the identifier (function name) is missing, hence the separator is parsed as not part of the call. * * Going back before the separator makes sure the separator is part of the call. */ override fun transformIndex( cursorIndex: Int, text: String, ) = cursorIndex - QuarkdownPatterns.FunctionCall.CHAIN_SEPARATOR.length override fun getCompletionItems( call: FunctionCall, function: DocumentedFunction?, cursorIndex: Int, originalCursorIndex: Int, ): List<CompletionItem> { if (!isCompletableCall(call, originalCursorIndex)) { return emptyList() } // The function name snippet to complete. val snippet = call .getTokenAtSourceIndex(originalCursorIndex) ?.takeIf { it.type == FunctionCallToken.Type.FUNCTION_NAME } ?.lexeme ?: "" return CacheableFunctionCatalogue .searchAll(super.docsDirectory, snippet) .map { it.toCompletionItem(chained = true) } .toList() } /** * Checks if the cursor is positioned in a function call chain that can be completed. * This means the cursor is either immediately after a chain separator or at a function name after a chain separator. * @param call the function call at the cursor position * @param index the index of the cursor in the source text * @return whether the cursor is positioned in a completable chained call */ private fun isCompletableCall( call: FunctionCall, index: Int, ): Boolean { // Case 1: // the cursor is at the beginning of a chained function call, so the chain separator // is not yet part of the call, since the identifier (function name) is missing. // For this reason, the separator will be in the remainder. if (call.remainderUntilIndex(index) == QuarkdownPatterns.FunctionCall.CHAIN_SEPARATOR) { return true } // Case 2: // the chain separator is already part of the call, so we check // if the token *before* the cursor is a chain separator. call.tokens.findMatchingTokenBeforeIndex( index, FunctionCallToken.Type.CHAINING_SEPARATOR, reset = setOf(FunctionCallToken.Type.FUNCTION_NAME), ) ?: return false return true } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/name/FunctionNameCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function.name import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.completion.CompletionSupplier import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams import java.io.File /** * Provides completion items for function names in function calls by scanning documentation files. * This supplier is proxied by [com.quarkdown.lsp.completion.function.FunctionCompletionSupplier]. * * A name completion can occur: * - At the beginning of a function call, e.g. `.xyz`. * - In a function call chain, e.g. `.abc::xyz`. * @param docsDirectory the directory containing the documentation files to extract function data from * @see RegularFunctionNameCompletionSupplier for `.xyz` style completions * @see ChainedFunctionNameCompletionSupplier for `.abc::xyz` style completions */ class FunctionNameCompletionSupplier( private val docsDirectory: File, ) : CompletionSupplier { // Completion for function names right after the function begin token ('.'). private val fromBegin = RegularFunctionNameCompletionSupplier(docsDirectory) // Completion for function names right after a function call chain token ('::'). private val fromChain = ChainedFunctionNameCompletionSupplier(docsDirectory) override fun getCompletionItems( params: CompletionParams, document: TextDocument, ): List<CompletionItem> = fromBegin.getCompletionItems(params, document).takeIf { it.isNotEmpty() } ?: fromChain.getCompletionItems(params, document) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/name/RegularFunctionNameCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function.name import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.cache.CacheableFunctionCatalogue import com.quarkdown.lsp.completion.CompletionSupplier import com.quarkdown.lsp.completion.toCompletionItem import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.lsp.util.getLineUntilPosition import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams import java.io.File /** * Provides completion items for regular (non-chained) function names by scanning documentation files. * * Let `|` be the cursor position in the text, this supplier provides completions for: * - `.|` * - `.func|` * * This supplier is proxied by [FunctionNameCompletionSupplier]. * @param docsDirectory the directory containing the documentation files to extract function data from */ class RegularFunctionNameCompletionSupplier( private val docsDirectory: File, ) : CompletionSupplier { // Pattern to match a function call at cursor position. private val callPattern = Regex("${QuarkdownPatterns.FunctionCall.identifierInCall}$") override fun getCompletionItems( params: CompletionParams, document: TextDocument, ): List<CompletionItem> { val text = document.text val line = params.position.getLineUntilPosition(text) ?: return emptyList() // The name of the function call at the cursor position to complete. val snippet: String = callPattern.find(line)?.value ?: return emptyList() return CacheableFunctionCatalogue .searchAll(this.docsDirectory, snippet) .map { it.toCompletionItem(chained = false) } .toList() } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/parameter/FunctionParameterAllowedValuesCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function.parameter import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.completion.function.AbstractFunctionCompletionSupplier import com.quarkdown.lsp.completion.toCompletionItem import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.tokenizer.getTokenAtSourceIndex import com.quarkdown.lsp.util.getParameterAtSourceIndex import com.quarkdown.quarkdoc.reader.DocsParameter import org.eclipse.lsp4j.CompletionItem import java.io.File /** * Provides completion items for fixed allowed values for a function parameter, if applicable. * For example, let `|` be the cursor position in the text, * `.row alignment:{|` will provide allowed values for the `alignment` parameter of the `row` function. * * This works for both named and positional arguments. */ class FunctionParameterAllowedValuesCompletionSupplier( docsDirectory: File, ) : AbstractFunctionCompletionSupplier(docsDirectory) { override fun getCompletionItems( call: FunctionCall, function: DocumentedFunction?, cursorIndex: Int, originalCursorIndex: Int, ): List<CompletionItem> { if (function == null) return emptyList() // If a value is partially present, it can be completed. // If no value is present, all allowed values are returned. val value: String = call .getTokenAtSourceIndex(cursorIndex) ?.takeIf { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_VALUE } ?.lexeme ?.trim() ?: "" // The parameter data looked up from documentation. val parameter: DocsParameter = call.getParameterAtSourceIndex(function.data, cursorIndex) ?: return emptyList() return parameter.allowedValues ?.filter { it.startsWith(value) } ?.map { it.toCompletionItem() } ?: emptyList() } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/completion/function/parameter/FunctionParameterNameCompletionSupplier.kt ================================================ package com.quarkdown.lsp.completion.function.parameter import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.completion.function.AbstractFunctionCompletionSupplier import com.quarkdown.lsp.completion.toCompletionItem import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.getTokenAtSourceIndex import com.quarkdown.lsp.util.remainderUntilIndex import org.eclipse.lsp4j.CompletionItem import java.io.File /** * Provides completion items for function parameter names. For example, let `|` be the cursor position in the text, * `.function pa|` will provide names for parameters starting with `pa`. */ class FunctionParameterNameCompletionSupplier( docsDirectory: File, ) : AbstractFunctionCompletionSupplier(docsDirectory) { /** * Transforms the cursor index to the index of the last whitespace before the cursor, * so that the returned index is always part of the function call. * * Note that, by design, an inline argument without both delimiters is not part of the function call. */ override fun transformIndex( cursorIndex: Int, text: String, ): Int = text .substring(0, cursorIndex) .indexOfLast { it.isWhitespace() } override fun getCompletionItems( call: FunctionCall, function: DocumentedFunction?, cursorIndex: Int, originalCursorIndex: Int, ): List<CompletionItem> { if (function == null) return emptyList() // Parameter names are only completed when the parameter name is being typed, so it's not yet part of the function call. if (call.getTokenAtSourceIndex(originalCursorIndex) != null) return emptyList() // The remainder of the function call before the cursor position. // For example, if the function call being completed is `.function param`, // the remainder is `param`. val remainder = call.remainderUntilIndex(originalCursorIndex)?.trim() ?: "" val arguments = call.parserResult.value.arguments return function.data.parameters .asSequence() .filter { it.name.startsWith(remainder) } .filter { param -> arguments.none { arg -> arg.name == param.name } } // Exclude already present parameters .map { it.toCompletionItem() } .toList() } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/AbstractFunctionCallDiagnosticsSupplier.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.cache.functionCalls import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.util.tokensByChainedCall /** * A [DiagnosticsSupplier] that provides diagnostics for function calls. * * In chained calls, diagnostics are provided for each function in the chain. */ abstract class AbstractFunctionCallDiagnosticsSupplier : DiagnosticsSupplier { override fun getDiagnostics(document: TextDocument): List<SimpleDiagnostic> = document.functionCalls.flatMap(::getDiagnostics) private fun getDiagnostics(call: FunctionCall): List<SimpleDiagnostic> = call.tokensByChainedCall .flatMap { (functionName, tokens) -> getDiagnostics(functionName, tokens, call) } .toList() /** * Provides diagnostics for a function call. * * If the function is part of a chained call, [functionName] is not granted to be the same as the name of [call]. * @param functionName the name of the function to provide diagnostics for * @param tokens the tokens of the function call * @param call the full function call * @return a list of diagnostics for the function call */ protected abstract fun getDiagnostics( functionName: String, tokens: List<FunctionCallToken>, call: FunctionCall, ): List<SimpleDiagnostic> } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/DiagnosticsSupplier.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.TextDocument /** * */ interface DiagnosticsSupplier { /** * Generates a list of diagnostics. * @param document the current document * @return a list of diagnostics that can be reported */ fun getDiagnostics(document: TextDocument): List<SimpleDiagnostic> } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/DiagnosticsSuppliersFactory.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.QuarkdownLanguageServer import com.quarkdown.lsp.diagnostics.function.FunctionDuplicateParameterNameDiagnosticsSupplier import com.quarkdown.lsp.diagnostics.function.FunctionParameterValueDiagnosticsSupplier import com.quarkdown.lsp.diagnostics.function.FunctionUnresolvedParameterNameDiagnosticsSupplier /** * Factory for creating a list of [DiagnosticsSupplier]s. */ object DiagnosticsSuppliersFactory { /** * @param server the Quarkdown language server instance * @return the default list of [DiagnosticsSuppliersFactory] instances */ fun default(server: QuarkdownLanguageServer): List<DiagnosticsSupplier> { val docsDirectory = server.docsDirectoryOrThrow() return listOf( FunctionParameterValueDiagnosticsSupplier(docsDirectory), FunctionUnresolvedParameterNameDiagnosticsSupplier(docsDirectory), FunctionDuplicateParameterNameDiagnosticsSupplier(), ) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/SimpleDiagnostic.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.diagnostics.cause.DiagnosticCause import com.quarkdown.lsp.util.offsetToPosition import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DiagnosticSeverity import org.eclipse.lsp4j.Range import org.eclipse.lsp4j.jsonrpc.messages.Either /** * A simple diagnostic that can be sent to the client. * @param range the range of the diagnostic in the source text * @param message the message of the diagnostic * @param severity the severity of the diagnostic */ data class SimpleDiagnostic( val range: IntRange, val message: String, val severity: DiagnosticSeverity, ) { /** * Creates a [SimpleDiagnostic] from a [DiagnosticCause]. * @param range the range of the diagnostic in the source text * @param cause the cause of the diagnostic */ constructor(range: IntRange, cause: DiagnosticCause) : this(range, cause.message, cause.severity) } /** * Converts a [SimpleDiagnostic] to a [Diagnostic] suitable for the LSP to send to the client. * @param text the text of the document * @return the LSP diagnostic */ fun SimpleDiagnostic.toLspDiagnostic(text: String): Diagnostic = Diagnostic().also { it.range = Range(offsetToPosition(text, range.first), offsetToPosition(text, range.endInclusive)) it.message = Either.forLeft(message) it.severity = severity } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/cause/DiagnosticCause.kt ================================================ package com.quarkdown.lsp.diagnostics.cause import org.eclipse.lsp4j.DiagnosticSeverity /** * The cause of a diagnostic. */ interface DiagnosticCause { /** * The message of the diagnostic. */ val message: String /** * The severity of the diagnostic. */ val severity: DiagnosticSeverity } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/cause/DuplicateParameterNameDiagnosticCause.kt ================================================ package com.quarkdown.lsp.diagnostics.cause import org.eclipse.lsp4j.DiagnosticSeverity /** * A diagnostic cause indicating that a parameter name appears multiple times in a function call. * @param parameterName the duplicate parameter name */ class DuplicateParameterNameDiagnosticCause( private val parameterName: String, ) : DiagnosticCause { override val message: String get() = "The parameter name '$parameterName' appears multiple times in the same function call." override val severity: DiagnosticSeverity get() = DiagnosticSeverity.Error } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/cause/UnallowedValueDiagnosticCause.kt ================================================ package com.quarkdown.lsp.diagnostics.cause import com.quarkdown.quarkdoc.reader.DocsParameter import org.eclipse.lsp4j.DiagnosticSeverity /** * A diagnostic cause indicating that a value is not among the allowed values (e.g. enum values) for a parameter. * @param parameter the parameter the value is for * @param value the invalid value */ class UnallowedValueDiagnosticCause( private val parameter: DocsParameter, private val value: String, ) : DiagnosticCause { override val message: String get() = """ Invalid value '$value' for parameter '${parameter.name}'. Allowed values are: ${parameter.allowedValues!!.joinToString(", ")} """.trimIndent() override val severity: DiagnosticSeverity get() = DiagnosticSeverity.Warning } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/cause/UnresolvedParameterNameDiagnosticCause.kt ================================================ package com.quarkdown.lsp.diagnostics.cause import com.quarkdown.quarkdoc.reader.DocsFunction import org.eclipse.lsp4j.DiagnosticSeverity /** * A diagnostic cause indicating that a parameter name used in a function call does not match any known parameters for that function. * @param function the function being called * @param parameterName the unresolved parameter name */ class UnresolvedParameterNameDiagnosticCause( private val function: DocsFunction, private val parameterName: String, ) : DiagnosticCause { override val message: String get() = """ Unknown parameter '$parameterName' for function '${function.name}'. Available parameters are: ${function.parameters.joinToString(", ") { it.name }}. """.trimIndent() override val severity: DiagnosticSeverity get() = DiagnosticSeverity.Error } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/function/FunctionDuplicateParameterNameDiagnosticsSupplier.kt ================================================ package com.quarkdown.lsp.diagnostics.function import com.quarkdown.lsp.diagnostics.AbstractFunctionCallDiagnosticsSupplier import com.quarkdown.lsp.diagnostics.SimpleDiagnostic import com.quarkdown.lsp.diagnostics.cause.DuplicateParameterNameDiagnosticCause import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken /** * A diagnostics supplier that checks for duplicate function parameter names in function calls. */ class FunctionDuplicateParameterNameDiagnosticsSupplier : AbstractFunctionCallDiagnosticsSupplier() { override fun getDiagnostics( functionName: String, tokens: List<FunctionCallToken>, call: FunctionCall, ): List<SimpleDiagnostic> = tokens .asSequence() .filter { it.type == FunctionCallToken.Type.PARAMETER_NAME } .groupBy { it.lexeme.trim() } .filter { (_, tokens) -> tokens.size > 1 } .flatMap { (parameterName, tokens) -> tokens.map { token -> SimpleDiagnostic(token.range, DuplicateParameterNameDiagnosticCause(parameterName)) } }.toList() } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/function/FunctionParameterValueDiagnosticsSupplier.kt ================================================ package com.quarkdown.lsp.diagnostics.function import com.quarkdown.lsp.diagnostics.AbstractFunctionCallDiagnosticsSupplier import com.quarkdown.lsp.diagnostics.SimpleDiagnostic import com.quarkdown.lsp.diagnostics.cause.DiagnosticCause import com.quarkdown.lsp.diagnostics.cause.UnallowedValueDiagnosticCause import com.quarkdown.lsp.documentation.getDocumentation import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.util.getParameterAtSourceIndex import com.quarkdown.quarkdoc.reader.DocsParameter import java.io.File /** * A diagnostics supplier that checks function parameter values against their allowed values as specified in the documentation. * @param docsDirectory the directory where function documentation files are stored */ class FunctionParameterValueDiagnosticsSupplier( private val docsDirectory: File, ) : AbstractFunctionCallDiagnosticsSupplier() { override fun getDiagnostics( functionName: String, tokens: List<FunctionCallToken>, call: FunctionCall, ): List<SimpleDiagnostic> { val function = getDocumentation(this.docsDirectory, functionName) ?: return emptyList() val valueTokens = call.tokens.filter { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_VALUE } val diagnostics = mutableListOf<SimpleDiagnostic>() // Validating each value to its corresponding parameter. valueTokens.forEach { token -> // Getting the parameter corresponding to this value. val parameter: DocsParameter = call.getParameterAtSourceIndex(function.data, token.range.start) ?: return@forEach // The value of the argument. val value = token.lexeme.trim() // Validating the value against the parameter. validate(parameter, value)?.let { diagnostics += SimpleDiagnostic(token.range, it) } } return diagnostics } /** * Validates a value against a parameter to extract any diagnostics. * @param parameter the parameter to validate against * @param value the value to validate * @return a [DiagnosticCause] if the value is invalid, `null` otherwise */ private fun validate( parameter: DocsParameter, value: String, ): DiagnosticCause? = when { // No diagnostics available if function calls are present in the value. QuarkdownPatterns.FunctionCall.identifierInCall in value -> null // If there are allowed values, checks if the value is among them. parameter.allowedValues?.let { value in it } == false -> UnallowedValueDiagnosticCause(parameter, value) else -> null } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/diagnostics/function/FunctionUnresolvedParameterNameDiagnosticsSupplier.kt ================================================ package com.quarkdown.lsp.diagnostics.function import com.quarkdown.lsp.diagnostics.AbstractFunctionCallDiagnosticsSupplier import com.quarkdown.lsp.diagnostics.SimpleDiagnostic import com.quarkdown.lsp.diagnostics.cause.DiagnosticCause import com.quarkdown.lsp.diagnostics.cause.UnresolvedParameterNameDiagnosticCause import com.quarkdown.lsp.documentation.getDocumentation import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.quarkdoc.reader.DocsFunction import java.io.File /** * A diagnostics supplier that checks the existence of function parameter names that named arguments refer to. * @param docsDirectory the directory where function documentation files are stored */ class FunctionUnresolvedParameterNameDiagnosticsSupplier( private val docsDirectory: File, ) : AbstractFunctionCallDiagnosticsSupplier() { override fun getDiagnostics( functionName: String, tokens: List<FunctionCallToken>, call: FunctionCall, ): List<SimpleDiagnostic> { val function = getDocumentation(this.docsDirectory, functionName) ?: return emptyList() return tokens .asSequence() .filter { it.type == FunctionCallToken.Type.PARAMETER_NAME } .mapNotNull { token -> val parameterName = token.lexeme.trim() validate(function.data, parameterName)?.let { cause -> SimpleDiagnostic(token.range, cause) } }.toList() } /** * Validates a parameter name against a function to extract any diagnostics about unresolved parameter names. * @param function the function to validate against * @param parameterName the parameter name to validate * @return a [DiagnosticCause] if the parameter name is unresolved, `null` */ private fun validate( function: DocsFunction, parameterName: String, ): DiagnosticCause? = when { // If there are allowed values, checks if the value is among them. function.parameters.none { it.name == parameterName } -> UnresolvedParameterNameDiagnosticCause(function, parameterName) else -> null } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/documentation/FunctionDocumentation.kt ================================================ package com.quarkdown.lsp.documentation import com.quarkdown.core.parser.walker.funcall.lastChainedCall import com.quarkdown.lsp.cache.CacheableFunctionCatalogue import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.tokenizer.FunctionCall import java.io.File /** * Retrieves the documentation for a function in the specified documentation directory. * @param docsDirectory the directory containing the documentation files * @param name name of the function to look up * @return the [DocumentedFunction] if found */ fun getDocumentation( docsDirectory: File, name: String, ): DocumentedFunction? = CacheableFunctionCatalogue .getCatalogue(docsDirectory) .find { it.name == name } /** * Retrieves the documentation for a function call in the specified documentation directory. * @param docsDirectory the directory containing the documentation files * @return the [DocumentedFunction] if found */ fun FunctionCall.getDocumentation(docsDirectory: File): DocumentedFunction? = getDocumentation(docsDirectory, this.parserResult.value.lastChainedCall.name) ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/documentation/HtmlToMarkdown.kt ================================================ package com.quarkdown.lsp.documentation import com.quarkdown.quarkdoc.reader.DocsContentExtractor import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter import org.eclipse.lsp4j.MarkupContent import org.eclipse.lsp4j.MarkupKind import org.jsoup.Jsoup /** * Helper to convert HTML to Markdown, suitable for use in LSP documentation. */ object HtmlToMarkdown { /** * Converts HTML to Markdown, suitable for use in LSP documentation. * @param html the HTML string to convert * @return the converted Markdown string */ fun convert(html: String): String { val processedHtml = Jsoup .parse(html) .apply { // Cleans up links in code blocks. select("pre code").forEach { it.text(it.wholeText()) } select(".table").forEach { it.tagName("ul") } select(".main-subrow").forEach { it.tagName("li") } select(".table h4").forEach { it.tagName("p") } select(".main-subrow > *").forEach { it.tagName("p") } select("u").forEach { it.tagName("strong") } } return FlexmarkHtmlConverter.builder().build().convert(processedHtml) } } /** * @return [this] HTML content converted to [MarkupContent] */ fun String.htmlToMarkup(): MarkupContent? = MarkupContent(MarkupKind.MARKDOWN, HtmlToMarkdown.convert(this)) /** * @return the content extracted from the documentation as [MarkupContent], or `null` if no content is available */ fun DocsContentExtractor.extractContentAsMarkup(): MarkupContent? = extractContent()?.htmlToMarkup() ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/SemanticTokensEncoder.kt ================================================ package com.quarkdown.lsp.highlight /** * Encodes a list of [SemanticTokenData] into the LSP semantic tokens format. */ object SemanticTokensEncoder { /** * Encodes a list of semantic tokens into a flat list of integers according to the LSP semantic tokens format. * The encoding format is as follows: * - delta line number * - delta start character * - length of the token * - token type * - token modifiers * * @param tokens the list of semantic tokens to encode * @return a flat list of integers representing the encoded tokens */ fun encode(tokens: List<SemanticTokenData>): List<Int> = buildList { var lastLine = 0 var lastChar = 0 val sortedTokens = tokens.asSequence().sortedWith(compareBy({ it.line }, { it.startChar })) sortedTokens.forEach { (line, startChar, length, tokenType, tokenModifiers) -> val deltaLine = line - lastLine val deltaStart = if (deltaLine == 0) startChar - lastChar else startChar lastLine = line lastChar = startChar add(deltaLine) add(deltaStart) add(length) add(tokenType) add(tokenModifiers) } } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/SemanticTokensSupplier.kt ================================================ package com.quarkdown.lsp.highlight import com.quarkdown.lsp.TextDocument import org.eclipse.lsp4j.SemanticTokensParams /** * Interface for providing semantic tokens based on the current context in a text document. */ interface SemanticTokensSupplier { /** * Generates a list of simplified semantic tokens, which will be converted to full semantic tokens later. * @param params the parameters for the semantic tokens request * @param document the current document * @return a list of semantic tokens that can be used for highlighting */ fun getTokens( params: SemanticTokensParams, document: TextDocument, ): Iterable<SimpleTokenData> } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/SemanticTokensSuppliersFactory.kt ================================================ package com.quarkdown.lsp.highlight import com.quarkdown.lsp.highlight.function.FunctionCallTokensSupplier /** * Factory for creating a list of [SemanticTokensSupplier]s. */ object SemanticTokensSuppliersFactory { /** * @return the default list of [SemanticTokensSupplier] instances */ fun default() = listOf( FunctionCallTokensSupplier(), ) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/TokenData.kt ================================================ package com.quarkdown.lsp.highlight import com.quarkdown.lsp.util.offsetToPosition /** * A simplified version of a semantic token, used for initial processing. * This can be converted to a full semantic token via [toSemanticData]. * @param range the character range, inclusive, of the token in the text * @param type the type of the token, which is an index into the semantic token legend */ data class SimpleTokenData( val range: IntRange, val type: TokenType, ) /** * A semantic token for use in semantic highlighting. * @param line zero-based line number of the token * @param startChar zero-based character offset at which the token starts * @param length length of the token in characters * @param tokenType encoded token type index * @param tokenModifiers encoded bitmask of token modifiers */ data class SemanticTokenData( val line: Int, val startChar: Int, val length: Int, val tokenType: Int, val tokenModifiers: Int, ) /** * Converts a [SimpleTokenData] to a full [SemanticTokenData] which adheres to the LSP specification. * @param text the text content of the document, used to calculate the position * @return a full semantic token data */ fun SimpleTokenData.toSemanticData(text: String): SemanticTokenData { val start = range.first val end = range.endInclusive val pos = offsetToPosition(text, start) val length = end - start return SemanticTokenData(pos.line, pos.character, length, type.index, 0) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/TokenType.kt ================================================ package com.quarkdown.lsp.highlight /** * Types of tokens used for semantic highlighting. * @param legendName the name of the token type, used in the semantic token legend */ enum class TokenType( val legendName: String, ) { /** * A function call identifier. * * ``` * .function * ^^^^^^^^ * ``` */ FUNCTION_CALL_IDENTIFIER("function"), /** * A function call chaining separator. * * ``` * .function1::function2 * ^^ * ``` */ FUNCTION_CALL_CHAINING_SEPARATOR("keyword"), /** * A named parameter in a function call. * * ``` * .function parameter:{...} * ^^^^^^^^^^ * ``` */ FUNCTION_CALL_NAMED_PARAMETER("parameter"), /** * Delimiters of an inline argument in a function call. * * ``` * .function {...} * ^ ^ * ``` */ FUNCTION_CALL_INLINE_ARGUMENT_DELIMITER("keyword"), /** * A number value. * * ``` * 20 * ``` */ NUMBER("number"), /** * A range value. * * ``` * 10..20 * ``` * * @see com.quarkdown.core.function.value.data.Range */ RANGE("number"), /** * A size value or a list of sizes. * * ``` * 10px 20em 5px 2in * ``` * * @see com.quarkdown.core.document.size.Sizes */ SIZE("number"), /** * A boolean value. * * ``` * yes/true/no/false * ``` */ BOOLEAN("keyword"), /** * An enum value. * * ``` * spacebetween * ``` */ ENUM("enum"), ; /** * The index of this token type in the semantic token legend. * This is used to encode the token type. */ val index: Int get() = ordinal companion object { /** * The semantic token legend, which maps token types to their names. * This is used in the LSP to provide a legend for semantic tokens * which can then be referenced via their [index]. */ val legend: List<String> = entries.map { it.legendName } } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/function/FunctionCallTokensSupplier.kt ================================================ package com.quarkdown.lsp.highlight.function import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.cache.functionCalls import com.quarkdown.lsp.highlight.SemanticTokensSupplier import com.quarkdown.lsp.highlight.SimpleTokenData import com.quarkdown.lsp.highlight.TokenType import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.BEGIN import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.BODY_ARGUMENT import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.CHAINING_SEPARATOR import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.FUNCTION_NAME import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.INLINE_ARGUMENT_END import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.INLINE_ARGUMENT_VALUE import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.NAMED_PARAMETER_DELIMITER import com.quarkdown.lsp.tokenizer.FunctionCallToken.Type.PARAMETER_NAME import org.eclipse.lsp4j.SemanticTokensParams /** * Supplier for semantic tokens that highlight function calls. */ class FunctionCallTokensSupplier : SemanticTokensSupplier { override fun getTokens( params: SemanticTokensParams, document: TextDocument, ): Iterable<SimpleTokenData> = document.functionCalls .asSequence() .flatMap { it.tokens } .map { it.toSimpleTokenData() } .filterNotNull() .toList() /** * Converts a [FunctionCallToken] to a [SimpleTokenData] suitable for semantic highlighting, * or returns `null` if the token type does not correspond to a highlightable token. */ private fun FunctionCallToken.toSimpleTokenData(): SimpleTokenData? { val type: TokenType? = when (type) { BEGIN, FUNCTION_NAME -> TokenType.FUNCTION_CALL_IDENTIFIER CHAINING_SEPARATOR -> TokenType.FUNCTION_CALL_CHAINING_SEPARATOR PARAMETER_NAME, NAMED_PARAMETER_DELIMITER -> TokenType.FUNCTION_CALL_NAMED_PARAMETER INLINE_ARGUMENT_BEGIN, INLINE_ARGUMENT_END -> TokenType.FUNCTION_CALL_INLINE_ARGUMENT_DELIMITER INLINE_ARGUMENT_VALUE -> ValueQualifier.getTokenType(lexeme.trim()) BODY_ARGUMENT -> null } return SimpleTokenData( type = type ?: return null, range = range, ) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/highlight/function/ValueQualifier.kt ================================================ package com.quarkdown.lsp.highlight.function import com.quarkdown.core.function.toQuarkdownNamingFormat import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.lsp.highlight.TokenType /** * Utility for determining the token type of Quarkdown values in their raw string form. * For instance, this is used by [FunctionCallTokensSupplier] to categorize the content of function call arguments. */ object ValueQualifier { /** * Identifies the semantic token type of the given text. * Supported types are, in order of precedence: * - Boolean ([ValueFactory.boolean]) * - Number ([ValueFactory.number]) * - Sizes ([ValueFactory.sizes]) * - Range ([ValueFactory.range]) * - Enum * @param text the text to categorize, e.g. the content of a function call argument * @return the appropriate [TokenType] for the text, or `null` if the text doesn't match any known value type */ fun getTokenType(text: String): TokenType? = when { text.isBlank() -> null isType { boolean(text) } -> TokenType.BOOLEAN isType { number(text) } -> TokenType.NUMBER isType { sizes(text) } -> TokenType.SIZE isType { range(text) } -> TokenType.RANGE isEnum(text) -> TokenType.ENUM else -> null } /** * Checks if the text represents a specific value type. * @param conversion a function that validates the text against a specific value type * @return whether the given text represents the specified value type */ private fun isType(conversion: ValueFactory.() -> Value<*>): Boolean = ValueFactory.tryOrNull(conversion) != null /** * Checks if the text is a valid enum name in Quarkdown format. * An enum name is considered valid if it matches the Quarkdown naming format and contains only word characters. * @param text the text to check * @return whether if the text is a valid enum name */ private fun isEnum(text: String): Boolean = text == text.toQuarkdownNamingFormat() && Regex("\\W") !in text } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/hover/HoverSupplier.kt ================================================ package com.quarkdown.lsp.hover import com.quarkdown.lsp.TextDocument import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams /** * Interface for providing hover information based on the current context in a text document. */ interface HoverSupplier { /** * Generates a hover object. * @param params the parameters for the hover request, including the position in the document * @param document the current document * @return a [Hover] object containing the hover information, or `null` if no hover information is available */ fun getHover( params: HoverParams, document: TextDocument, ): Hover? } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/hover/HoverSuppliersFactory.kt ================================================ package com.quarkdown.lsp.hover import com.quarkdown.lsp.QuarkdownLanguageServer import com.quarkdown.lsp.hover.function.FunctionDocumentationHoverSupplier /** * Factory for creating a list of [HoverSupplier]s. */ object HoverSuppliersFactory { /** * @param server the Quarkdown language server instance * @return the default list of [HoverSupplier] instances */ fun default(server: QuarkdownLanguageServer) = listOf( FunctionDocumentationHoverSupplier( docsDirectory = server.docsDirectoryOrThrow(), ), ) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/hover/function/FunctionDocumentationHoverSupplier.kt ================================================ package com.quarkdown.lsp.hover.function import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.cache.DocumentedFunction import com.quarkdown.lsp.cache.functionCalls import com.quarkdown.lsp.documentation.getDocumentation import com.quarkdown.lsp.hover.HoverSupplier import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.tokenizer.getAtSourceIndex import com.quarkdown.lsp.tokenizer.getTokenAtSourceIndex import com.quarkdown.lsp.util.toOffset import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams import java.io.File /** * Provider of documentation on hover for function calls. * @property docsDirectory the directory containing the documentation files */ class FunctionDocumentationHoverSupplier( private val docsDirectory: File, ) : HoverSupplier { override fun getHover( params: HoverParams, document: TextDocument, ): Hover? { val text = document.text // Gets the function call at the specified hover position. val index = params.position.toOffset(text) val call: FunctionCall = document.functionCalls .getAtSourceIndex(index) ?: return null // If the hover position is over a function name in the chain, shows documentation for that specific function. // Otherwise, shows documentation for the last function in the chain. val nameToken: FunctionCallToken? = call .getTokenAtSourceIndex(index) ?.takeIf { it.type == FunctionCallToken.Type.FUNCTION_NAME } // Returns the documentation to display in the hover. val function: DocumentedFunction = getDocumentation(docsDirectory, nameToken?.lexeme ?: call.lastChainedName) ?: return null return Hover(function.documentationAsMarkup) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/ontype/OnTypeFormattingEditSupplier.kt ================================================ package com.quarkdown.lsp.ontype import com.quarkdown.lsp.TextDocument import org.eclipse.lsp4j.DocumentOnTypeFormattingParams import org.eclipse.lsp4j.TextEdit /** * Supplier of text edits for on-type formatting. */ interface OnTypeFormattingEditSupplier { /** * Provides text edits for on-type formatting based on the given parameters and document. * @param params the parameters for the on-type formatting request * @param document the text document to format * @return a list of text edits to apply to the document */ fun getEdits( params: DocumentOnTypeFormattingParams, document: TextDocument, ): List<TextEdit> } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/ontype/OnTypeFormattingSuppliersFactory.kt ================================================ package com.quarkdown.lsp.ontype /** * Factory for creating [OnTypeFormattingEditSupplier]s. */ object OnTypeFormattingSuppliersFactory { fun default(): List<OnTypeFormattingEditSupplier> = listOf(TrailingSpacesRemoverOnTypeFormattingEditSupplier()) } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/ontype/TrailingSpacesRemoverOnTypeFormattingEditSupplier.kt ================================================ package com.quarkdown.lsp.ontype import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.util.getLine import org.eclipse.lsp4j.DocumentOnTypeFormattingParams import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.Range import org.eclipse.lsp4j.TextEdit private const val TO_REMOVE = " " /** * Formatter that removes a single trailing space at the end of the previous line when the user types a newline, * but keeps double (or more) spaces which are significant in Markdown for hard line breaks. */ class TrailingSpacesRemoverOnTypeFormattingEditSupplier : OnTypeFormattingEditSupplier { override fun getEdits( params: DocumentOnTypeFormattingParams, document: TextDocument, ): List<TextEdit> { val lineNum = params.position.line - 1 // Line before the newline. val line = document.text.getLine(lineNum) ?: return emptyList() // No such line. if (!line.endsWith(TO_REMOVE)) return emptyList() // No trailing space. if (line.endsWith(TO_REMOVE + TO_REMOVE)) return emptyList() // More than one trailing space. val edit = TextEdit( Range( Position(lineNum, line.length - TO_REMOVE.length), Position(lineNum, line.length), ), "", ) return listOf(edit) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/pattern/QuarkdownPatterns.kt ================================================ package com.quarkdown.lsp.pattern import com.quarkdown.core.lexer.patterns.FUNCTION_CALL_PATTERN_BEFORE import com.quarkdown.core.lexer.regex.RegexBuilder import com.quarkdown.core.parser.walker.funcall.FunctionCallGrammar import com.quarkdown.lsp.pattern.QuarkdownPatterns.FunctionCall.BEGIN /** * Patterns used by the Quarkdown lexer. */ object QuarkdownPatterns { /** * Patterns related to function calls. */ object FunctionCall { /** * The character that prefixes a function call. */ const val BEGIN: String = FunctionCallGrammar.BEGIN.toString() /** * The pattern for an identifier (function name or argument name). */ val IDENTIFIER: Regex = FunctionCallGrammar.IDENTIFIER_PATTERN.toRegex() /** * The pattern that chains function calls together. */ const val CHAIN_SEPARATOR: String = FunctionCallGrammar.CHAIN_SEPARATOR /** * The character that begins an inline argument. */ const val ARGUMENT_BEGIN = FunctionCallGrammar.ARGUMENT_BEGIN.toString() /** * The character that ends an inline argument. */ const val ARGUMENT_END = FunctionCallGrammar.ARGUMENT_END.toString() /** * The character that delimits a named argument. */ const val NAMED_ARGUMENT_DELIMITER = FunctionCallGrammar.NAMED_ARGUMENT_DELIMITER /** * Default/suggested indentation for the body argument of a function call. */ const val CONVENTIONAL_BODY_INDENT = " " /** * The pattern that matches an optional identifier in a function call, preceded by [BEGIN] (unmatched). */ val identifierInCall: Regex = RegexBuilder("(?<=(before)(begin))(identifier)?") .withReference("before", FUNCTION_CALL_PATTERN_BEFORE) .withReference("begin", Regex.escape(BEGIN)) .withReference("chain", Regex.escape(CHAIN_SEPARATOR)) .withReference("identifier", IDENTIFIER.pattern) .buildRegex() } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/CompletionSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.completion.CompletionSupplier import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams /** * Subservice for handling completion requests. * Only the first non-empty result from the suppliers is returned. * @param completionSuppliers suppliers of completion items */ class CompletionSubservice( private val completionSuppliers: List<CompletionSupplier>, ) : TextDocumentSubservice<CompletionParams, List<CompletionItem>> { override fun process( params: CompletionParams, document: TextDocument, ): List<CompletionItem> = completionSuppliers .asSequence() .map { it.getCompletionItems(params, document) } .firstOrNull { it.isNotEmpty() } ?: emptyList() } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/DiagnosticsSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.diagnostics.DiagnosticsSupplier import com.quarkdown.lsp.diagnostics.toLspDiagnostic import org.eclipse.lsp4j.Diagnostic /** * Subservice for handling diagnostics. * @param diagnosticsSuppliers suppliers of diagnostic results */ class DiagnosticsSubservice( private val diagnosticsSuppliers: List<DiagnosticsSupplier>, ) : TextDocumentSubservice<Any?, List<Diagnostic>> { override fun process( params: Any?, document: TextDocument, ): List<Diagnostic> = diagnosticsSuppliers .flatMap { it.getDiagnostics(document) } .map { it.toLspDiagnostic(document.text) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/HoverSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.hover.HoverSupplier import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams /** * Subservice for handling hover requests. * It gathers hover information from multiple suppliers, and picks the first non-null result. * @param hoverSuppliers suppliers of hover information */ class HoverSubservice( private val hoverSuppliers: List<HoverSupplier>, ) : TextDocumentSubservice<HoverParams, Hover?> { override fun process( params: HoverParams, document: TextDocument, ): Hover? = hoverSuppliers .asSequence() .mapNotNull { it.getHover(params, document) } .firstOrNull() } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/OnTypeFormattingSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.ontype.OnTypeFormattingEditSupplier import org.eclipse.lsp4j.DocumentOnTypeFormattingParams import org.eclipse.lsp4j.TextEdit /** * Subservice for handling on-type formatting requests. * It aggregates edits from all suppliers and returns them as a single list. */ class OnTypeFormattingSubservice( private val editSuppliers: List<OnTypeFormattingEditSupplier>, ) : TextDocumentSubservice<DocumentOnTypeFormattingParams, List<TextEdit>> { override fun process( params: DocumentOnTypeFormattingParams, document: TextDocument, ): List<TextEdit> = editSuppliers.flatMap { it.getEdits(params, document) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/SemanticTokensSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import com.quarkdown.lsp.highlight.SemanticTokenData import com.quarkdown.lsp.highlight.SemanticTokensEncoder import com.quarkdown.lsp.highlight.SemanticTokensSupplier import com.quarkdown.lsp.highlight.toSemanticData import org.eclipse.lsp4j.SemanticTokens import org.eclipse.lsp4j.SemanticTokensParams /** * Subservice for handling semantic tokens requests. * @param tokensSuppliers suppliers of semantic tokens */ class SemanticTokensSubservice( private val tokensSuppliers: List<SemanticTokensSupplier>, ) : TextDocumentSubservice<SemanticTokensParams, SemanticTokens> { override fun process( params: SemanticTokensParams, document: TextDocument, ): SemanticTokens { val tokens: List<SemanticTokenData> = this.tokensSuppliers .flatMap { it.getTokens(params, document) } .map { it.toSemanticData(document.text) } val encoded = SemanticTokensEncoder.encode(tokens) return SemanticTokens(encoded) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/subservices/TextDocumentSubservice.kt ================================================ package com.quarkdown.lsp.subservices import com.quarkdown.lsp.TextDocument import org.eclipse.lsp4j.services.TextDocumentService /** * Represents a single operation that is part of a [TextDocumentService]. * * This is to ensure the main service does not break single-responsibility principles. * @param P type of the parameters * @param O type of the output of the operation */ interface TextDocumentSubservice<P, O> { /** * Processes the given parameters and text to produce an output. * @param params the parameters for the operation * @param document the current document * @return the output of the operation */ fun process( params: P, document: TextDocument, ): O } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/tokenizer/FunctionCall.kt ================================================ package com.quarkdown.lsp.tokenizer import com.quarkdown.core.parser.walker.WalkerParsingResult import com.quarkdown.core.parser.walker.funcall.WalkedFunctionCall import com.quarkdown.core.parser.walker.funcall.lastChainedCall /** * A function call in Quarkdown source code. * @param range the range in the source code where this function call appears * @param tokens the list of tokens (parts) that make up this function call * @param parserResult the result of parsing this function call */ data class FunctionCall( val range: IntRange, val tokens: List<FunctionCallToken>, val parserResult: WalkerParsingResult<WalkedFunctionCall>, ) { /** * The name of the last function in a chain of function calls. * * For example, in `.func1 param:{value1}::func2 param:{value2}`, this would be `func2`. */ val lastChainedName: String get() = parserResult.value.lastChainedCall.name } /** * A token within a function call which represents a specific part of the function call syntax * such as the function name, delimiters, argument values, etc. * @param type the type of this token, indicating its role in the function call * @param range the range in the source text where this token appears * @param lexeme the actual text of this token, which is the part of the source code */ data class FunctionCallToken( val type: Type, val range: IntRange, val lexeme: String, ) { /** * Represents the different types of tokens that can appear in a function call. */ enum class Type { /** The beginning of a function call (typically `.`) */ BEGIN, /** The name of the function being called. */ FUNCTION_NAME, /** The separator for chaining function calls. */ CHAINING_SEPARATOR, /** The name of a parameter in a named parameter. */ PARAMETER_NAME, /** The delimiter between a parameter name and its value. */ NAMED_PARAMETER_DELIMITER, /** The beginning of an inline argument. */ INLINE_ARGUMENT_BEGIN, /** The content of an inline argument. */ INLINE_ARGUMENT_VALUE, /** The end of an inline argument. */ INLINE_ARGUMENT_END, /** A body argument. */ BODY_ARGUMENT, } } /** * Finds the innermost function call that contains the specified index * (relative to the source code the function call was tokenized from). * @param index the source index to search for * @return the innermost function call containing the index, if any */ fun Iterable<FunctionCall>.getAtSourceIndex(index: Int): FunctionCall? = this .asSequence() .sortedBy { it.range.last - it.range.first } // Sorting by length, making sure to target innermost calls over their parent. .firstOrNull { index in it.range } /** * Finds the token within a function call that contains the specified index * (relative to the source code the function call was tokenized from). * @param index the source index to search for * @return the token at the specified index, if any */ fun FunctionCall.getTokenAtSourceIndex(index: Int): FunctionCallToken? = this.tokens.find { index in it.range } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/tokenizer/FunctionCallTokenizer.kt ================================================ package com.quarkdown.lsp.tokenizer import com.github.h0tk3y.betterParse.lexer.TokenMatch import com.quarkdown.core.flavor.quarkdown.QuarkdownLexerFactory import com.quarkdown.core.util.offset import com.quarkdown.core.lexer.tokens.FunctionCallToken as CoreFunctionCallToken // Names of the tokens of the function call grammar (see core/FunctionCallGrammar). private const val FUNCTION_CALL_BEGIN_TOKEN_NAME = "begin" private const val FUNCTION_CALL_IDENTIFIER_TOKEN_NAME = "identifier" private const val FUNCTION_CALL_CHAINING_SEPARATOR_NAME = "chainSeparator" private const val FUNCTION_CALL_PARAMETER_NAME_DELIMITER_TOKEN_NAME = "argumentNameDelimiter" private const val FUNCTION_CALL_ARGUMENT_CONTENT_TOKEN_NAME = "argContent" private const val FUNCTION_CALL_INLINE_ARGUMENT_BEGIN_TOKEN_NAME = "argumentBegin" private const val FUNCTION_CALL_INLINE_ARGUMENT_END_TOKEN_NAME = "argumentEnd" private const val FUNCTION_CALL_INLINE_ARGUMENT_CONTENT_TOKEN_NAME = "argContent" /** * Tokenizes function calls in text content. * * This class is responsible for parsing text to identify function calls and their components, * such as function names, parameters, and arguments. It uses a lexer to tokenize the text * and then processes the tokens to create [FunctionCall] objects. * * This is a lightweight approach to function call tokenization, more efficient than using a full parser. */ class FunctionCallTokenizer { /** * Parses the given text to extract function calls, tokenizing the input text and processing the tokens * to identify function calls and their components. It also handles nested function * calls by recursively processing function call arguments. * @param text the text to parse for function calls * @return the [FunctionCall]s found in the text */ fun getFunctionCalls(text: String): List<FunctionCall> { val lexer = QuarkdownLexerFactory.newInlineFunctionCallLexer(text) // When a function call argument is met, its content is enqueued // and its tokens are extracted at the end of the main tokenization. val tokenizationQueue = mutableMapOf<Int, String>() val calls: List<FunctionCall> = lexer .tokenize() .filterIsInstance<CoreFunctionCallToken>() .map { token -> val result = token.walkerResult val start = token.data.position.first val end = start + result.endIndex var lastToken: FunctionCallToken.Type? = null // Function call are special tokens, as they are processed by a walker // which produces nested tokens for each part of the call (e.g. name and parameters). // A semantic token is created for each eligible part. val tokens: List<FunctionCallToken> = result.tokens .takeWhile { it.offset < result.endIndex } .mapNotNull { match -> val start = token.data.position.first + match.offset // Enqueuing the tokenization of function call arguments. if (match.type.name == FUNCTION_CALL_ARGUMENT_CONTENT_TOKEN_NAME) { tokenizationQueue += start to match.text } lastToken = tokenMatchToType(match, lastToken) FunctionCallToken( type = lastToken ?: return@mapNotNull null, range = start..(start + match.length), lexeme = match.text, ) }.toList() FunctionCall( range = start..end, tokens = tokens, parserResult = result, ) }.toList() return calls + extractEnqueuedTokens(tokenizationQueue) } /** * Extracts tokens from the enqueued function call arguments. * * Each argument is tokenized separately by recursively calling [getFunctionCalls], * and the resulting tokens are adjusted to account for their original offset in the source text. * This allows for proper handling of nested function calls within arguments. * * @param queue A map of offsets to argument content strings * @return A list of [FunctionCall] objects representing the function calls found in the arguments */ private fun extractEnqueuedTokens(queue: Map<Int, String>): List<FunctionCall> = queue.flatMap { (offset, text) -> getFunctionCalls(text).map { it.copy( range = it.range.offset(offset), tokens = it.tokens.map { token -> token.copy(range = token.range.offset(offset)) }, ) } } /** * Maps a token match to a specific [FunctionCallToken.Type]. * * This method determines the appropriate token type based on the token's name and position * in the function call. It handles various parts of a function call such as the beginning * marker, function name, parameter names, delimiters, and argument values. * * @param match The token match to map to a type * @return The corresponding [FunctionCallToken.Type], or `null` if the token should be ignored */ private fun tokenMatchToType( match: TokenMatch, previous: FunctionCallToken.Type?, ): FunctionCallToken.Type? = when (match.type.name) { // .function // ^ FUNCTION_CALL_BEGIN_TOKEN_NAME -> { FunctionCallToken.Type.BEGIN } // .function::function parameter:{...} // ^^^^^^^^ ^^^^^^^^ ^^^^^^^^^ (depending on the last token) FUNCTION_CALL_IDENTIFIER_TOKEN_NAME -> { when (previous) { FunctionCallToken.Type.BEGIN, FunctionCallToken.Type.CHAINING_SEPARATOR -> { FunctionCallToken.Type.FUNCTION_NAME } else -> { FunctionCallToken.Type.PARAMETER_NAME } } } // .function::function // ^^ FUNCTION_CALL_CHAINING_SEPARATOR_NAME -> { FunctionCallToken.Type.CHAINING_SEPARATOR } // .function parameter:{...} // ^ FUNCTION_CALL_PARAMETER_NAME_DELIMITER_TOKEN_NAME -> { FunctionCallToken.Type.NAMED_PARAMETER_DELIMITER } // .function {...} // ^ FUNCTION_CALL_INLINE_ARGUMENT_BEGIN_TOKEN_NAME -> { FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN } // .function {...} // ^ FUNCTION_CALL_INLINE_ARGUMENT_END_TOKEN_NAME -> { FunctionCallToken.Type.INLINE_ARGUMENT_END } // .function {...} // ^^^ FUNCTION_CALL_INLINE_ARGUMENT_CONTENT_TOKEN_NAME -> { FunctionCallToken.Type.INLINE_ARGUMENT_VALUE } else -> { null } } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/tokenizer/TokenSearch.kt ================================================ package com.quarkdown.lsp.tokenizer /** * Finds the last token of a specific type before a given index in the list of function call tokens. * If a reset token is encountered, it discards the match. * * Example (let `|` be the cursor position in the text): * ``` * .function param1:{arg1} param2:{ar|g2} * ``` * * Calling this function with: * - the position of `|` (the cursor) as the index; * - [FunctionCallToken.Type.PARAMETER_NAME] as the match type; * - [FunctionCallToken.Type.INLINE_ARGUMENT_END] as the reset type * will return the token for `param2` (the last parameter name before the cursor). * * @param beforeIndex the index before which to search for the matching token * @param matchType the type of token to match * @param reset a set of token types that will reset the match if encountered * @return the last matching token before the specified index, or `null` if no match is found */ fun List<FunctionCallToken>.findMatchingTokenBeforeIndex( beforeIndex: Int, matchType: FunctionCallToken.Type, reset: Set<FunctionCallToken.Type>, ): FunctionCallToken? { var match: FunctionCallToken? = null for (token in this) { if (token.type == matchType) { match = token } if (beforeIndex in token.range) { return match } if (token.type in reset) { match = null } } return match } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/util/FunctionParameterLookupUtils.kt ================================================ package com.quarkdown.lsp.util import com.quarkdown.core.parser.walker.funcall.WalkedFunctionArgument import com.quarkdown.core.parser.walker.funcall.WalkedFunctionCall import com.quarkdown.core.util.offset import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.quarkdoc.reader.DocsFunction import com.quarkdown.quarkdoc.reader.DocsParameter /** * Finds the argument at the given source index in this function call. * Note that, by design, the range is between the argument's delimiters, excluding the parameter name if any. * @param index the source index where the argument appears * @return the argument at the given index, if any */ fun FunctionCall.getArgumentAtSourceIndex(index: Int): WalkedFunctionArgument? = this.parserResult.value.arguments .find { index in it.range.offset(this.range.first) } /** * Finds the parameter corresponding to the argument at the given source index in this function call. * The argument can be either named or positional, and the lookup happens against the provided function documentation. * @param function the documented function whose parameters are to be searched * @param index the source index where the argument appears * @return the parameter corresponding to the argument, if any */ fun FunctionCall.getParameterAtSourceIndex( function: DocsFunction, index: Int, ): DocsParameter? { val parameters = function.parameters.takeIf { it.isNotEmpty() } ?: return null val argument: WalkedFunctionArgument = this.getArgumentAtSourceIndex(index) ?: return null // The parameter whose value being completed, either named or positional. return getParameterByName(argument.name, parameters) ?: getParameterByPosition(this.parserResult.value, argument, parameters) } /** * Finds a parameter by its name. * @param name the name of the parameter to find * @param parameters the list of parameters to search in * @return the parameter with the given name, if any */ private fun getParameterByName( name: String?, parameters: List<DocsParameter>, ): DocsParameter? = name?.let { paramName -> parameters.find { it.name == paramName } } /** * Finds a parameter by the position of its corresponding argument in the function call. * Note that all arguments before the target argument must be positional as well. * If a named argument appears before the target argument, `null` is returned. * @param call the function call containing the argument * @param argument the argument whose parameter to find * @param parameters the list of parameters to search in * @return the parameter corresponding to the argument's position, if any */ private fun getParameterByPosition( call: WalkedFunctionCall, argument: WalkedFunctionArgument, parameters: List<DocsParameter>, ): DocsParameter? { for ((index, arg) in call.arguments.withIndex()) { // A positional argument must appear before any named one. if (arg.name != null) return null // Match by position. if (arg == argument) return parameters.getOrNull(index) } return null } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/util/ParsingUtils.kt ================================================ package com.quarkdown.lsp.util import com.quarkdown.core.util.substringWithinBounds import com.quarkdown.lsp.tokenizer.FunctionCall import com.quarkdown.lsp.tokenizer.FunctionCallToken /** * Returns the remainder of the parsing result, truncated to the specified index relative to the original source text. * * For example, if the source is `"Hello, world!"` and the parsing completes with `"Hello"`, * the regular remainder would be `", world!"`. * If this function is called with an index of `10`, it will return `", wor"`. * @param index the index to which the remainder should be returned * @return the substring from the start of the remainder to the specified index */ fun FunctionCall.remainderUntilIndex(index: Int): String? = parserResult.remainder .substringWithinBounds(0, index - range.endInclusive) /** * Given a function call that may contain chained calls, e.g. `.func1 param:{value1}::func2 param:{value2}`, * which would be normally tokenized as a flat list of tokens, * this property returns a sequence of (function name, tokens) pairs for each function in the chain. * * Note that tokens that occur before any function name, such as `.`, are ignored. */ val FunctionCall.tokensByChainedCall: Sequence<Pair<String, List<FunctionCallToken>>> get() = sequence { var currentFunctionName: String? = null var currentTokens = mutableListOf<FunctionCallToken>() tokens.forEach { token -> if (token.type == FunctionCallToken.Type.FUNCTION_NAME) { currentFunctionName?.let { yield(it to currentTokens.toList()) } currentFunctionName = token.lexeme currentTokens = mutableListOf() } else { currentFunctionName?.let { currentTokens.add(token) } } } currentFunctionName?.let { yield(it to currentTokens.toList()) } } ================================================ FILE: quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/util/PositionUtils.kt ================================================ package com.quarkdown.lsp.util import com.quarkdown.core.util.substringWithinBounds import org.eclipse.lsp4j.Position /** * @param line the line number (0-based) * @return the line at the specified index, or null if the index is out of bounds */ fun String.getLine(line: Int): String? = lines().getOrNull(line) /** * @param text the text content to search in * @return the character at the specified position, or null if the position is out of bounds */ fun Position.getChar(text: String): Char? = text.getLine(line)?.getOrNull(character - 1) /** * @param text the text content to search in * @return the substring from the start of the line, up to the specified position, or `null` if the position is out of bounds */ fun Position.getLineUntilPosition(text: String): String? = text.getLine(line)?.substringWithinBounds(0, character) /** * @param text the text content to search in * @return the substring that matches the given pattern and contains the given position, or null if no match is found */ fun Position.getByPatternContaining( pattern: Regex, text: String, ): String? { val lineText = text.lines().getOrNull(line) ?: return null return pattern .findAll(lineText) .firstOrNull { it.range.contains(character) } ?.value } /** * Converts a character offset in the text to a [Position]. * @param text the text content to search in * @param offset the character offset to convert * @return the [Position] corresponding to the given offset */ fun offsetToPosition( text: String, offset: Int, ): Position { var line = 0 var lastLineStart = 0 for (i in 0 until offset) { if (text[i] == '\n') { line++ lastLineStart = i + 1 } } val character = offset - lastLineStart return Position(line, character) } /** * Converts a [Position] to a character offset (index) in the text. * @param text the text content to search in * @return the character offset corresponding to the given position, or -1 if the position is out of bounds */ fun Position.toOffset(text: String): Int { val lines = text.lines() if (line < 0 || line >= lines.size) return -1 val lineText = lines[line] if (character < 0 || character > lineText.length) return -1 return lines.take(line).sumOf { it.length + 1 } + character // +1 for the newline character } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/DocumentCacheTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.cache.DocumentCache import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue /** * Tests for document caching functionality, particularly for function calls. */ class DocumentCacheTest { @Test fun `cache is initially null`() { val doc = TextDocument("text") assertNull(doc.cache) } @Test fun `compute creates cache with function calls`() { val text = ".function {arg}" val cache = DocumentCache.compute(TextDocument(text)) assertNotNull(cache) assertTrue(cache.functionCalls.isNotEmpty()) assertEquals(1, cache.functionCalls.size) val functionCall = cache.functionCalls.first() assertEquals(0..text.length, functionCall.range) assertTrue(functionCall.tokens.any { it.lexeme == "function" }) } @Test fun `compute handles multiple function calls`() { val text = ".function1 {arg1} .function2 {arg2}" val cache = DocumentCache.compute(TextDocument(text)) assertEquals(2, cache.functionCalls.size) val function1 = cache.functionCalls.first() val function2 = cache.functionCalls.last() assertTrue(function1.tokens.any { it.lexeme == "function1" }) assertTrue(function2.tokens.any { it.lexeme == "function2" }) } @Test fun `compute handles nested function calls`() { val text = ".outer {.inner {arg}}" val cache = DocumentCache.compute(TextDocument(text)) assertTrue(cache.functionCalls.size >= 2) // Find the outer and inner function calls val outerCall = cache.functionCalls.find { call -> call.tokens.any { it.lexeme == "outer" } } val innerCall = cache.functionCalls.find { call -> call.tokens.any { it.lexeme == "inner" } } assertNotNull(outerCall) assertNotNull(innerCall) // Verify the inner call is within the range of the outer call assertTrue(innerCall.range.first >= outerCall.range.first) assertTrue(innerCall.range.last <= outerCall.range.last) } @Test fun `cacheOrCompute returns existing cache if available`() { val text = ".function {arg}" // Create a real cache using compute val initialCache = DocumentCache.compute(TextDocument(text)) val doc = TextDocument(text, initialCache) val retrievedCache = doc.cacheOrCompute assertSame(initialCache, retrievedCache) } @Test fun `cacheOrCompute computes new cache if none exists`() { val text = ".function {arg}" val doc = TextDocument(text) val computedCache = doc.cacheOrCompute assertNotNull(computedCache) assertTrue(computedCache.functionCalls.isNotEmpty()) assertEquals(1, computedCache.functionCalls.size) } @Test fun `updateCache returns new document with updated cache`() { val doc = TextDocument(".function {arg}") val newCache = DocumentCache(emptyList()) val updatedDoc = doc.updateCache(newCache) assertSame(newCache, updatedDoc.cache) } @Test fun `invalidateCache returns new document with null cache`() { val initialCache = DocumentCache(emptyList()) val doc = TextDocument(".function {arg}", initialCache) val invalidatedDoc = doc.invalidateCache() assertNull(invalidatedDoc.cache) } @Test fun `setActive overwrites active document`() { val docs = mutableMapOf<String, TextDocument>() val key = "key" docs[key] = TextDocument(".function {arg}", setActive = { docs[key] = this }) val doc = docs[key]!! val newDoc = doc.updateCache(DocumentCache.compute(doc)) assertNotNull(newDoc.cache) assertNotSame(docs[key], newDoc) newDoc.setActive(newDoc) assertSame(docs[key], newDoc) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/FunctionCallTokenizerTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.lsp.tokenizer.FunctionCallToken import com.quarkdown.lsp.tokenizer.FunctionCallTokenizer import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue /** * Tests for [com.quarkdown.lsp.tokenizer.FunctionCallTokenizer]. * * These tests verify that the tokenizer correctly identifies function calls * and their components in various text patterns. */ class FunctionCallTokenizerTest { private val tokenizer = FunctionCallTokenizer() @Test fun `simple function call tokenization`() { val text = ".function" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() assertEquals(0..9, call.range) // Verify tokens val tokens = call.tokens assertEquals(2, tokens.size) val beginToken = tokens.find { it.type == FunctionCallToken.Type.BEGIN } assertNotNull(beginToken) assertEquals(QuarkdownPatterns.FunctionCall.BEGIN, beginToken.lexeme) assertEquals(0..1, beginToken.range) val nameToken = tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME } assertNotNull(nameToken) assertEquals("function", nameToken.lexeme) assertEquals(1..9, nameToken.range) } @Test fun `function call with named parameter`() { val text = ".function param:{value}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val tokens = call.tokens // Verify parameter tokens val paramNameToken = tokens.find { it.type == FunctionCallToken.Type.PARAMETER_NAME } assertNotNull(paramNameToken) assertEquals("param", paramNameToken.lexeme) val delimiterToken = tokens.find { it.type == FunctionCallToken.Type.NAMED_PARAMETER_DELIMITER } assertNotNull(delimiterToken) assertEquals(QuarkdownPatterns.FunctionCall.NAMED_ARGUMENT_DELIMITER, delimiterToken.lexeme) } @Test fun `function call with inline argument`() { val text = ".function {argument content}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val tokens = call.tokens // Verify inline argument tokens val beginToken = tokens.find { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN } assertNotNull(beginToken) assertEquals(QuarkdownPatterns.FunctionCall.ARGUMENT_BEGIN, beginToken.lexeme) val valueToken = tokens.find { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_VALUE } assertNotNull(valueToken) assertEquals("argument content", valueToken.lexeme) val endToken = tokens.find { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_END } assertNotNull(endToken) assertEquals(QuarkdownPatterns.FunctionCall.ARGUMENT_END, endToken.lexeme) } @Test fun `function call with chaining`() { val text = ".function1::function2" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val tokens = call.tokens.iterator() assertEquals(FunctionCallToken.Type.BEGIN, tokens.next().type) assertEquals(FunctionCallToken.Type.FUNCTION_NAME, tokens.next().type) assertEquals(FunctionCallToken.Type.CHAINING_SEPARATOR, tokens.next().type) assertEquals(FunctionCallToken.Type.FUNCTION_NAME, tokens.next().type) assertFalse(tokens.hasNext()) } @Test fun `function call with chaining and args`() { val text = ".function1 {arg} name:{arg}::function2 {arg}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val tokens = call.tokens.iterator() assertEquals(FunctionCallToken.Type.BEGIN, tokens.next().type) assertEquals(FunctionCallToken.Type.FUNCTION_NAME, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_VALUE, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, tokens.next().type) assertEquals(FunctionCallToken.Type.PARAMETER_NAME, tokens.next().type) assertEquals(FunctionCallToken.Type.NAMED_PARAMETER_DELIMITER, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_VALUE, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, tokens.next().type) assertEquals(FunctionCallToken.Type.CHAINING_SEPARATOR, tokens.next().type) assertEquals(FunctionCallToken.Type.FUNCTION_NAME, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_VALUE, tokens.next().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, tokens.next().type) assertFalse(tokens.hasNext()) } @Test fun `nested function calls`() { val text = ".outer {.inner {nested content}}" val calls = tokenizer.getFunctionCalls(text) // Should find both outer and inner function calls assertTrue(calls.size >= 2) // Verify outer call val outerCall = calls.find { it.tokens.any { token -> token.type == FunctionCallToken.Type.FUNCTION_NAME && token.lexeme == "outer" } } assertNotNull(outerCall) assertEquals(0..text.length, outerCall.range) // Verify inner call val innerCall = calls.find { it.tokens.any { token -> token.type == FunctionCallToken.Type.FUNCTION_NAME && token.lexeme == "inner" } } assertNotNull(innerCall) assertEquals(8..31, innerCall.range) } @Test fun `multiple function calls in text`() { val text = "Text .function1 {arg1} more text .function2 param:{value}" val calls = tokenizer.getFunctionCalls(text) assertEquals(2, calls.size) // Verify function names val functionNames = calls.flatMap { call -> call.tokens.filter { it.type == FunctionCallToken.Type.FUNCTION_NAME }.map { it.lexeme } } assertTrue("function1" in functionNames) assertTrue("function2" in functionNames) } @Test fun `wrapped function call`() { val text = "{.function {x}}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() assertEquals(0..text.length, call.range) val nameToken = call.tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME } assertNotNull(nameToken) assertEquals("function", nameToken.lexeme) // The wrapping braces appear as argument delimiters in the grammar tokens. val argBeginTokens = call.tokens.filter { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN } val argEndTokens = call.tokens.filter { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_END } // Two opens: wrap '{' + argument '{'. Two closes: argument '}' + wrap '}'. assertEquals(2, argBeginTokens.size) assertEquals(2, argEndTokens.size) } @Test fun `tight wrapped function call`() { val text = "hello{.func {x}}hello" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val nameToken = call.tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME } assertNotNull(nameToken) assertEquals("func", nameToken.lexeme) // The call range starts at '{' and ends after the closing '}'. assertEquals(5..16, call.range) } @Test fun `wrapped function call with chaining`() { val text = "{.func1 {x}::func2 {y}}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val functionNames = call.tokens .filter { it.type == FunctionCallToken.Type.FUNCTION_NAME } .map { it.lexeme } assertEquals(listOf("func1", "func2"), functionNames) // The wrapping braces are the first and last tokens. assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, call.tokens.first().type) assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, call.tokens.last().type) } @Test fun `wrapped function call with named parameter`() { val text = "{.function param:{value}}" val calls = tokenizer.getFunctionCalls(text) assertEquals(1, calls.size) val call = calls.first() val paramNameToken = call.tokens.find { it.type == FunctionCallToken.Type.PARAMETER_NAME } assertNotNull(paramNameToken) assertEquals("param", paramNameToken.lexeme) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/FunctionCallTokensSupplierTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.lsp.highlight.SimpleTokenData import com.quarkdown.lsp.highlight.TokenType import com.quarkdown.lsp.highlight.function.FunctionCallTokensSupplier import org.eclipse.lsp4j.SemanticTokensParams import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse // Token type of the 'begin' token in a function call. private val TYPE_BEGIN = TokenType.FUNCTION_CALL_IDENTIFIER // Token type of the function call name. private val TYPE_NAME = TokenType.FUNCTION_CALL_IDENTIFIER // Token type of the function call chaining separator. private val TYPE_CHAINING_SEPARATOR = TokenType.FUNCTION_CALL_CHAINING_SEPARATOR // Token type of the named parameter in a function call. private val TYPE_NAMED_PARAMETER = TokenType.FUNCTION_CALL_NAMED_PARAMETER // Token type of the ':' separator between a named parameter and its value. private val TYPE_NAMED_PARAMETER_SEPARATOR = TokenType.FUNCTION_CALL_NAMED_PARAMETER // Token type of the '{' and '}' brackets of a function call argument. private val TYPE_INLINE_ARGUMENT_DELIMITER = TokenType.FUNCTION_CALL_INLINE_ARGUMENT_DELIMITER /** * Tests for tokenization of function calls. */ class FunctionCallTokensSupplierTest { private val supplier = FunctionCallTokensSupplier() private val params = SemanticTokensParams() private fun tokenize( text: String, block: Iterator<SimpleTokenData>.() -> Unit = {}, ): Iterator<SimpleTokenData> = supplier .getTokens(params, TextDocument(text)) .iterator() .also { tokens -> block(tokens) assertFalse(tokens.hasNext(), "Expected no more tokens after processing") } private fun Iterator<SimpleTokenData>.assertNext( type: TokenType, range: IntRange, ) { val token = next() assertEquals(type, token.type) assertEquals(range, token.range) } @Test fun `no calls`() { tokenize("This is a test without function calls.") } @Test fun `only function call, no args`() { tokenize(".funcall") { // 'Begin' assertNext(TYPE_BEGIN, 0..1) // Name assertNext(TYPE_NAME, 1..8) } } @Test fun `function call followed by a dot`() { tokenize(".funcall.") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..8) } } @Test fun `function call in other content, no args`() { tokenize("some text .funcall some other text.") { assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..18) } } @Test fun `two function calls, no args, no other content`() { tokenize(".funcall1 .funcall2") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..9) assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..19) } } @Test fun `two function calls, no args, with other content`() { tokenize("some text .funcall1 some other text .funcall2.") { assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..19) assertNext(TYPE_BEGIN, 36..37) assertNext(TYPE_NAME, 37..45) } } @Test fun `function call with one positional argument`() { tokenize("some text .funcall {arg1} some other text.") { assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..18) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 19..20) // The argument content "arg1" is now recognized as an enum value assertNext(TokenType.ENUM, 20..24) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 24..25) } } @Test fun `function call with one named argument`() { tokenize("some text .funcall name:{arg1} some other text.") { assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..18) assertNext(TYPE_NAMED_PARAMETER, 19..23) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 23..24) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 24..25) assertNext(TokenType.ENUM, 25..29) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 29..30) } } @Test fun `function call with one positional and one named argument`() { tokenize("some text .funcall {arg1} name:{arg2} some other text.") { assertNext(TYPE_BEGIN, 10..11) assertNext(TYPE_NAME, 11..18) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 19..20) assertNext(TokenType.ENUM, 20..24) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 24..25) assertNext(TYPE_NAMED_PARAMETER, 26..30) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 30..31) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 31..32) assertNext(TokenType.ENUM, 32..36) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 36..37) } } @Test fun `function call with multiple positional and named arguments`() { tokenize(".funcall {value1} {value2} firstnamed:{value3} secondnamed:{value with {nested}}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..8) // First positional argument. assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 9..10) assertNext(TokenType.ENUM, 10..16) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 16..17) // Second positional argument. assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 18..19) assertNext(TokenType.ENUM, 19..25) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 25..26) // First named argument. assertNext(TYPE_NAMED_PARAMETER, 27..37) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 37..38) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 38..39) assertNext(TokenType.ENUM, 39..45) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 45..46) // Second named argument. assertNext(TYPE_NAMED_PARAMETER, 47..58) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 58..59) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 59..60) // The argument content "value with {nested}" is not recognized as any specific value type. assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 79..80) // Note: Nested braces within arguments are not tokenized separately. } } @Test fun `multiline function call`() { tokenize( """ .x a:{ } b:{} c:{ } .y """.trimIndent().normalizeLineSeparators().toString(), ) { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..2) assertNext(TYPE_NAMED_PARAMETER, 3..4) // a assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 4..5) // : assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 5..6) // { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 7..8) // } assertNext(TYPE_NAMED_PARAMETER, 9..10) // b assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 10..11) // : assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 11..12) // { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) // } assertNext(TYPE_NAMED_PARAMETER, 14..15) // c assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 15..16) // : assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 16..17) // { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 18..19) // } assertNext(TYPE_BEGIN, 21..22) // . assertNext(TYPE_NAME, 22..23) // y } } @Test fun `chained function calls`() { tokenize(".func1::func2") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..6) assertNext(TYPE_CHAINING_SEPARATOR, 6..8) assertNext(TYPE_NAME, 8..13) } } @Test fun `function call with nested braces in argument`() { tokenize(".func param:{{nested}}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 21..22) // Note: Nested braces within arguments are not tokenized separately. } } @Test fun `function call with escaped characters`() { tokenize(".func param:{content with \\{ escaped brace}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 42..43) // Note: The escaped characters in the argument are not tokenized separately. } } @Test fun `function call with body argument`() { tokenize( """ .blockfunc This is a body argument that spans multiple lines with different indentation """.trimIndent().normalizeLineSeparators().toString(), ) { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..10) // Note: The body argument content itself is not tokenized as semantic tokens. } } @Test fun `function call with mixed inline and body arguments`() { tokenize( """ .mixed param1:{inline} named:{value} This is a body argument following inline arguments """.trimIndent().normalizeLineSeparators().toString(), ) { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..6) // The tokenizer identifies "param1" as a named parameter. assertNext(TYPE_NAMED_PARAMETER, 7..13) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 13..14) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 14..15) assertNext(TokenType.ENUM, 15..21) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 21..22) // The tokenizer identifies "named" as a named parameter. assertNext(TYPE_NAMED_PARAMETER, 23..28) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 28..29) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 29..30) assertNext(TokenType.ENUM, 30..35) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 35..36) // Note: Body argument content is not tokenized as semantic tokens. } } // Value types @Test fun `boolean argument`() { tokenize(".func param:{yes}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.BOOLEAN, 13..16) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 16..17) } } @Test fun `number argument`() { tokenize(".func param:{42}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.NUMBER, 13..15) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 15..16) } } @Test fun `size argument`() { tokenize(".func param:{10px}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.SIZE, 13..17) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 17..18) } } @Test fun `sizes argument`() { tokenize(".func param:{10px 20px 10in 50%}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.SIZE, 13..31) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 31..32) } } @Test fun `range argument`() { tokenize(".func param:{1..5}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.RANGE, 13..17) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 17..18) } } @Test fun `enum argument`() { tokenize(".func param:{spacebetween}") { assertNext(TYPE_BEGIN, 0..1) assertNext(TYPE_NAME, 1..5) assertNext(TYPE_NAMED_PARAMETER, 6..11) assertNext(TYPE_NAMED_PARAMETER_SEPARATOR, 11..12) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 12..13) assertNext(TokenType.ENUM, 13..25) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 25..26) } } // Wrapped (tight) function calls @Test fun `wrapped function call, no args`() { tokenize("{.funcall}") { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 0..1) // Wrap open '{' assertNext(TYPE_BEGIN, 1..2) // '.' assertNext(TYPE_NAME, 2..9) // 'funcall' assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 9..10) // Wrap close '}' } } @Test fun `wrapped function call with one positional argument`() { tokenize("{.funcall {arg1}}") { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 0..1) // Wrap open assertNext(TYPE_BEGIN, 1..2) assertNext(TYPE_NAME, 2..9) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 10..11) // Arg open assertNext(TokenType.ENUM, 11..15) // arg1 assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 15..16) // Arg close assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 16..17) // Wrap close } } @Test fun `tight wrapped function call in surrounding text`() { tokenize("hello{.funcall {arg1}}hello") { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 5..6) // Wrap open assertNext(TYPE_BEGIN, 6..7) assertNext(TYPE_NAME, 7..14) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 15..16) // Arg open assertNext(TokenType.ENUM, 16..20) // arg1 assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 20..21) // Arg close assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 21..22) // Wrap close } } @Test fun `wrapped chained function calls`() { tokenize("{.func1::func2}") { assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 0..1) // Wrap open assertNext(TYPE_BEGIN, 1..2) assertNext(TYPE_NAME, 2..7) assertNext(TYPE_CHAINING_SEPARATOR, 7..9) assertNext(TYPE_NAME, 9..14) assertNext(TYPE_INLINE_ARGUMENT_DELIMITER, 14..15) // Wrap close } } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/FunctionCompletionSupplierTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.completion.CompletionSuppliersFactory import com.quarkdown.lsp.subservices.CompletionSubservice import org.eclipse.lsp4j.CompletionItem import org.eclipse.lsp4j.CompletionParams import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.TextDocumentIdentifier import java.io.File import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue private const val ALIGN_FUNCTION = "align" private const val CLIP_FUNCTION = "clip" private const val COLUMN_FUNCTION = "column" private const val CSV_FUNCTION = "csv" private const val LAYOUT_MODULE = "Layout" private const val DATA_MODULE = "Data" private const val ALIGNMENT_PARAMETER = "alignment" private const val BODY_PARAMETER = "body" private const val CAPTION_PARAMETER = "caption" /** * Tests for the completion of function calls. */ class FunctionCompletionSupplierTest { private val docsDirectory = File("src/test/resources/docs") private val suppliers = CompletionSuppliersFactory.functions(docsDirectory) private val testDocumentUri = "file:///test.qd" private fun getCompletions( text: String, position: Position, ): List<CompletionItem> { val textDocument = TextDocumentIdentifier(testDocumentUri) val params = CompletionParams(textDocument, position) val document = TextDocument(text = text) return CompletionSubservice(suppliers).process(params, document) } @Test fun `no completions at empty position`() { val text = "" val completions = getCompletions(text, Position(0, 0)) assertTrue(completions.isEmpty()) } @Test fun `no completions outside function call`() { val text = "hello world" val completions = getCompletions(text, Position(0, text.length)) assertTrue(completions.isEmpty()) } @Test fun `no completions in invalid function call position`() { val text = "hello." val position = Position(0, text.length) val completions = getCompletions(text, position) assertTrue(completions.isEmpty()) } @Test fun `completions at beginning of function call`() { val text = "hello ." val completions = getCompletions(text, Position(0, text.length)) assertEquals(4, completions.size) // Verifies the expected function names are present. val labels = completions.map { it.label }.toSet() assertContains(labels, ALIGN_FUNCTION) assertContains(labels, CLIP_FUNCTION) assertContains(labels, COLUMN_FUNCTION) assertContains(labels, CSV_FUNCTION) // Verifies module names are correct. completions .filter { it.label == ALIGN_FUNCTION || it.label == CLIP_FUNCTION } .forEach { assertEquals(LAYOUT_MODULE, it.detail) } completions .filter { it.label == CSV_FUNCTION } .forEach { assertEquals(DATA_MODULE, it.detail) } // Verifies the insertion snippet is correct. // Only mandatory parameters. val alignCompletion = completions.first { it.label == ALIGN_FUNCTION } assertEquals( "align {\${1:alignment (start|center|end)}} \n \${2:body}", alignCompletion.insertText, ) // One optional parameter. val csvCompletion = completions.first { it.label == CSV_FUNCTION } assertEquals( "csv {\${1:path}} ", csvCompletion.insertText, ) // Only optional parameters + body. val columnCompletion = completions.first { it.label == COLUMN_FUNCTION } assertEquals( "column \${1:}\n \${2:body}", columnCompletion.insertText, ) } @Test fun `name completions for partial function name`() { val text = "hello .ali" val completions = getCompletions(text, Position(0, text.length)) assertEquals(1, completions.size) assertEquals(ALIGN_FUNCTION, completions.first().label) assertEquals(LAYOUT_MODULE, completions.first().detail) } @Test fun `name completion in chain for empty name`() { val text = "hello .$ALIGN_FUNCTION::" val completions = getCompletions(text, Position(0, text.length)) assertEquals(4, completions.size) } @Test fun `name completion in chain with args`() { val text = "hello .$ALIGN_FUNCTION {arg}::" val completions = getCompletions(text, Position(0, text.length)) assertEquals(4, completions.size) } @Test fun `name completion in chain for partial function name`() { val text = "hello .$ALIGN_FUNCTION::c" val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertEquals(3, completions.size) assertContains(completions, CSV_FUNCTION) assertContains(completions, COLUMN_FUNCTION) assertContains(completions, CLIP_FUNCTION) } @Test fun `name completion in chain with other content`() { val suffix = " abc" val text = "hello .$ALIGN_FUNCTION::$suffix" val completions = getCompletions(text, Position(0, text.length - suffix.length)) assertEquals(4, completions.size) } @Test fun `name completion in chain should skip first parameter`() { val text = "hello .$ALIGN_FUNCTION::cs" val completions = getCompletions(text, Position(0, text.length)) assertEquals(1, completions.size) val completion = completions.single() assertEquals(CSV_FUNCTION, completion.label) // The call is chained, so the first parameter (which should be the path) is skipped. assertEquals(CSV_FUNCTION, completion.insertText.trim()) } @Test fun `parameter completions at beginning of parameter list`() { val text = "hello .$ALIGN_FUNCTION " val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertEquals(2, completions.size) assertContains(completions, ALIGNMENT_PARAMETER) assertContains(completions, BODY_PARAMETER) } @Test fun `parameter completions in the middle of parameter list`() { val suffix = " x:{arg}" val text = "hello .$ALIGN_FUNCTION $suffix" val completions = getCompletions(text, Position(0, text.length - suffix.length)).map { it.label } assertEquals(2, completions.size) assertContains(completions, ALIGNMENT_PARAMETER) assertContains(completions, BODY_PARAMETER) } @Test fun `parameter completions with partial parameter name`() { val text = "hello .$ALIGN_FUNCTION align" val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertFalse(BODY_PARAMETER in completions) assertEquals(ALIGNMENT_PARAMETER, completions.single()) } @Test fun `parameter completions after line break`() { val text = "abc\n\n.$ALIGN_FUNCTION " val position = Position(text.lines().lastIndex, text.lines().last().length) val completions = getCompletions(text, position).map { it.label } assertContains(completions, ALIGNMENT_PARAMETER) } @Test fun `parameter completions before and after line break`() { val text = "abc\n\n.$ALIGN_FUNCTION \n\ndef" val position = Position(2, text.lines()[2].length) val completions = getCompletions(text, position).map { it.label } assertContains(completions, ALIGNMENT_PARAMETER) } @Test fun `parameter completions with some parameters already specified`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center} " val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertFalse(ALIGNMENT_PARAMETER in completions) assertEquals(BODY_PARAMETER, completions.single()) } @Test fun `no parameter completions when all parameters are specified`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center} $BODY_PARAMETER:{content} " val completions = getCompletions(text, Position(0, text.length)) assertTrue(completions.isEmpty()) } @Test fun `parameter completions in nested function call`() { val text = "hello .func {.$ALIGN_FUNCTION al}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertEquals(ALIGNMENT_PARAMETER, completions.single()) } @Test fun `parameter completions with nested function call`() { val text = "hello .$CSV_FUNCTION {.func {arg}} capt" val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertEquals(CAPTION_PARAMETER, completions.single()) } @Test fun `parameter completions should not happen in empty values`() { val text = "hello .$CSV_FUNCTION {}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertTrue(completions.isEmpty()) } @Test fun `parameter completions should not happen in partial values`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{$BODY_PARAMETER}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertTrue(completions.isEmpty()) } @Test fun `parameter value by name, empty argument`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertEquals(3, completions.size) assertContains(completions, "start") assertContains(completions, "center") assertContains(completions, "end") } @Test fun `parameter value by name, partial argument`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{cen}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertEquals(completions.single(), "center") } @Test fun `parameter value by position, partial argument`() { val text = "hello .$ALIGN_FUNCTION {cen}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertEquals(completions.single(), "center") } @Test fun `parameter value by position should not be preceded by named arguments`() { val text = "hello .$ALIGN_FUNCTION x:{} {cen}" val completions = getCompletions(text, Position(0, text.length - 1)).map { it.label } assertTrue(completions.isEmpty()) } // Wrapped (tight) function calls @Test fun `name completions in wrapped function call`() { val text = "hello{." val completions = getCompletions(text, Position(0, text.length)) assertEquals(4, completions.size) } @Test fun `name completions in wrapped function call for partial name`() { val text = "hello{.ali" val completions = getCompletions(text, Position(0, text.length)) assertEquals(1, completions.size) assertEquals(ALIGN_FUNCTION, completions.first().label) } @Test fun `name completions in closed wrapped function call for partial name`() { val text = "hello{.ali}" // Cursor before the closing brace. val completions = getCompletions(text, Position(0, text.length - 1)) assertEquals(1, completions.size) assertEquals(ALIGN_FUNCTION, completions.first().label) } @Test fun `parameter completions in wrapped function call`() { val text = "hello{.$ALIGN_FUNCTION " val completions = getCompletions(text, Position(0, text.length)).map { it.label } assertEquals(2, completions.size) assertContains(completions, ALIGNMENT_PARAMETER) assertContains(completions, BODY_PARAMETER) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/FunctionDocumentationHoverSupplierTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.hover.function.FunctionDocumentationHoverSupplier import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.HoverParams import org.eclipse.lsp4j.Position import java.io.File import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertNotNull import kotlin.test.assertNull private const val ALIGN_FUNCTION = "align" private const val CSV_FUNCTION = "csv" /** * Tests for [FunctionDocumentationHoverSupplier]. */ class FunctionDocumentationHoverSupplierTest { private val testDocsDirectory = File("src/test/resources/docs") private val supplier = FunctionDocumentationHoverSupplier(testDocsDirectory) /** * Helper function to get hover information for a given text and position. */ private fun getHover( text: String, position: Position, ): Hover? { val params = HoverParams() params.position = position val document = TextDocument(text = text) return supplier.getHover(params, document) } @Test fun `hover outside function call returns null`() { val text = "This is a test with a .$ALIGN_FUNCTION call." val position = Position(0, 5) assertNull(getHover(text, position)) } @Test fun `hover over function call`() { val text = "This is a test with a .$ALIGN_FUNCTION call." val position = Position(0, text.indexOf(ALIGN_FUNCTION) + ALIGN_FUNCTION.length / 2) val hover = getHover(text, position) assertNotNull(hover) assertContains( hover.contents.right.value, "#### Parameters", ) } @Test fun `hover over function call argument`() { val text = "This is a test with a .$ALIGN_FUNCTION {center} call." val position = Position(0, text.indexOf("center")) assertNotNull(getHover(text, position)) } @Test fun `hover over chained function call`() { val text = "This is a test with a .$ALIGN_FUNCTION::$CSV_FUNCTION {arg}" val csvHover = getHover(text, Position(0, text.length - 1)) val alignHover = getHover(text, Position(0, text.indexOf(ALIGN_FUNCTION) + ALIGN_FUNCTION.length / 2)) assertNotNull(csvHover) assertContains( csvHover.contents.right.value, CSV_FUNCTION, ) assertNotNull(alignHover) assertContains( alignHover.contents.right.value, ALIGN_FUNCTION, ) } @Test fun `hover over nested function call`() { val text = "This is a test with a .$ALIGN_FUNCTION {.$CSV_FUNCTION} call." val alignPosition = Position(0, text.indexOf(ALIGN_FUNCTION)) val csvPosition = Position(0, text.indexOf(CSV_FUNCTION)) val alignHover = getHover(text, alignPosition) val csvHover = getHover(text, csvPosition) assertNotNull(alignHover) assertContains( alignHover.contents.right.value, ".$ALIGN_FUNCTION", ) assertNotNull(csvHover) assertContains( csvHover.contents.right.value, ".$CSV_FUNCTION", ) } // Wrapped (tight) function calls @Test fun `hover over wrapped function call`() { val text = "hello{.$ALIGN_FUNCTION {center}}hello" val position = Position(0, text.indexOf(ALIGN_FUNCTION) + ALIGN_FUNCTION.length / 2) val hover = getHover(text, position) assertNotNull(hover) assertContains(hover.contents.right.value, ".$ALIGN_FUNCTION") } @Test fun `hover outside wrapped function call returns null`() { val text = "hello{.$ALIGN_FUNCTION {center}}hello" // Position on "hello" before the wrap. assertNull(getHover(text, Position(0, 2))) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/HtmlToMarkdownTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.lsp.documentation.HtmlToMarkdown import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for HTML-to-Markdown conversion for LSP documentation. */ class HtmlToMarkdownTest { @Test fun `stdlib page`() { val html = javaClass.getResourceAsStream("/html-to-markdown/align.html")!!.bufferedReader().use { it.readText() } val md = javaClass.getResourceAsStream("/html-to-markdown/align.md")!!.bufferedReader().use { it.readText() } assertEquals( md.normalizeLineSeparators(), HtmlToMarkdown.convert(html).normalizeLineSeparators(), ) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/PositionTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.lsp.pattern.QuarkdownPatterns import com.quarkdown.lsp.util.getByPatternContaining import com.quarkdown.lsp.util.getChar import org.eclipse.lsp4j.Position import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull /** * Tests for the LSP4J Position utility functions. */ class PositionTest { @Test fun `get char`() { val text = "Hello\nWorld" val position = Position(1, 3) val char = position.getChar(text) assert(char == 'r') } @Test fun `get function call name, beginning of the word`() { val text = "hello .funcall {x} world" val position = Position(0, 8) // Position of 'f' in 'funcall' val functionCallName = position.getByPatternContaining( pattern = QuarkdownPatterns.FunctionCall.identifierInCall, text = text, ) assertEquals("funcall", functionCallName) } @Test fun `get function call name, mid-word`() { val text = "hello .funcall {x} world" val position = Position(0, 10) // Position of 'n' in 'funcall' val functionCallName = position.getByPatternContaining( pattern = QuarkdownPatterns.FunctionCall.identifierInCall, text = text, ) assertEquals("funcall", functionCallName) } @Test fun `get function call name, not in function call`() { val text = "hello .funcall {x} world" val position = Position(0, 5) // Position of 'o' in 'hello' val functionCallName = position.getByPatternContaining( pattern = QuarkdownPatterns.FunctionCall.identifierInCall, text = text, ) assertNull(functionCallName) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/TrailingSpacesRemoverFormattingEditSupplierTest.kt ================================================ package com.quarkdown.lsp import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.lsp.ontype.TrailingSpacesRemoverOnTypeFormattingEditSupplier import org.eclipse.lsp4j.DocumentOnTypeFormattingParams import org.eclipse.lsp4j.FormattingOptions import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.TextDocumentIdentifier import org.eclipse.lsp4j.TextEdit import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [TrailingSpacesRemoverOnTypeFormattingEditSupplier]. */ class TrailingSpacesRemoverFormattingEditSupplierTest { private val supplier = TrailingSpacesRemoverOnTypeFormattingEditSupplier() private fun getEdits( text: String, atLine: Int, ): List<TextEdit> { val doc = TextDocument(text.normalizeLineSeparators().toString()) val options = FormattingOptions(2, true) val params = DocumentOnTypeFormattingParams(TextDocumentIdentifier("mem://test.md"), options, Position(atLine, 0), "\n") return supplier.getEdits(params, doc) } @Test fun `removes single trailing space`() { val text = "Hello \n" val edits = getEdits(text, 1) assertEquals(1, edits.size) assertEquals( 5, edits .single() .range.start.character, ) assertEquals( 6, edits .single() .range.end.character, ) } @Test fun `removes single trailing spaces among multiple lines`() { val text = "Hello \nWorld \nThis is a test \n" val edits = getEdits(text, 3) assertEquals(1, edits.size) } @Test fun `keeps double trailing space`() { val text = "Hello \n" assertEquals(0, getEdits(text, 1).size) } @Test fun `no trailing space to remove`() { val text = "Hello\n" assertEquals(0, getEdits(text, 1).size) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/diagnostics/DiagnosticsTestUtils.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.TextDocument import java.io.File /** * Constants and utility methods for diagnostics tests. */ object DiagnosticsTestUtils { const val ALIGN_FUNCTION = "align" const val ALIGNMENT_PARAMETER = "alignment" const val CSV_FUNCTION = "csv" const val PATH_PARAMETER = "path" val DOCS_DIRECTORY = File("src/test/resources/docs") /** * Gets diagnostics from a supplier for the given text. */ fun getDiagnostics( text: String, supplier: DiagnosticsSupplier, ): List<SimpleDiagnostic> { val document = TextDocument(text = text) return supplier.getDiagnostics(document) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/diagnostics/FunctionDuplicateParameterNameDiagnosticsSupplierTest.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGNMENT_PARAMETER import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGN_FUNCTION import com.quarkdown.lsp.diagnostics.function.FunctionDuplicateParameterNameDiagnosticsSupplier import org.eclipse.lsp4j.DiagnosticSeverity import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for the diagnostics supplier for duplicate parameter names in function calls. */ class FunctionDuplicateParameterNameDiagnosticsSupplierTest { private val supplier = FunctionDuplicateParameterNameDiagnosticsSupplier() private fun getDiagnostics(text: String): List<SimpleDiagnostic> = DiagnosticsTestUtils.getDiagnostics(text, supplier) @Test fun `no diagnostics in empty text`() { assertTrue(getDiagnostics("").isEmpty()) } @Test fun `no diagnostics in non-function text`() { val text = "hello world" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for function with no parameters`() { val text = "hello .$ALIGN_FUNCTION" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for function with unique parameter names`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center} another:{value}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `error for function with duplicate parameter names`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center} $ALIGNMENT_PARAMETER:{start}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) diagnostics.forEach { diagnostic -> assertEquals(DiagnosticSeverity.Error, diagnostic.severity) assertTrue(diagnostic.range.start >= text.indexOf(ALIGNMENT_PARAMETER)) assertTrue(diagnostic.range.endInclusive <= text.length) } } @Test fun `error for function with multiple duplicate parameter names`() { val text = "hello .$ALIGN_FUNCTION param:{value} param:{other} another:{x} another:{y}" val diagnostics = getDiagnostics(text) assertEquals(4, diagnostics.size) } @Test fun `error for function with more than two duplicates of a parameter name`() { val text = "hello .$ALIGN_FUNCTION param:{value} param:{other} param:{x}" val diagnostics = getDiagnostics(text) assertEquals(3, diagnostics.size) } @Test fun `error for duplicate parameter names in nested function call`() { val text = "hello .func {.$ALIGN_FUNCTION param:{value} param:{other}}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) } @Test fun `error also for unresolved functions`() { val text = "hello .myfunc param:{value} param:{other}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) } @Test fun `same parameter names in different chained calls are not duplicates`() { val text = "hello .firstfunc param:{value}::secondfunc param:{value}" val diagnostics = getDiagnostics(text) assertTrue(diagnostics.isEmpty()) } @Test fun `same parameter names in the same chained calls are duplicates`() { val text = "hello .firstfunc firstparam:{value} firstparam:{value}::secondfunc secondparam:{value} secondparam:{value}" val diagnostics = getDiagnostics(text) assertEquals(4, diagnostics.size) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/diagnostics/FunctionParameterValueDiagnosticsSupplierTest.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGNMENT_PARAMETER import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGN_FUNCTION import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.CSV_FUNCTION import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.DOCS_DIRECTORY import com.quarkdown.lsp.diagnostics.function.FunctionParameterValueDiagnosticsSupplier import org.eclipse.lsp4j.DiagnosticSeverity import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for the diagnostics supplier for function calls. */ class FunctionParameterValueDiagnosticsSupplierTest { private val supplier = FunctionParameterValueDiagnosticsSupplier(DOCS_DIRECTORY) private fun getDiagnostics(text: String): List<SimpleDiagnostic> = DiagnosticsTestUtils.getDiagnostics(text, supplier) @Test fun `no diagnostics in empty text`() { assertTrue(getDiagnostics("").isEmpty()) } @Test fun `no diagnostics in non-restricted function`() { val text = "hello .csv {somevalue}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for correct value, parameter by name`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for correct value, parameter by position`() { val text = "hello .$ALIGN_FUNCTION {center}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `warning in parameter by name, partial value`() { val value = "cen" val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{$value}" val diagnostics = getDiagnostics(text) assertEquals(1, diagnostics.size) val diagnostic = diagnostics.single() assertEquals(DiagnosticSeverity.Warning, diagnostic.severity) assertEquals(text.indexOf(value), diagnostic.range.start) assertEquals(text.length - 1, diagnostic.range.endInclusive) } @Test fun `warning in parameter by position, partial value`() { val text = "hello .$ALIGN_FUNCTION {cen}" val diagnostics = getDiagnostics(text) assertEquals(1, diagnostics.size) } @Test fun `no diagnostics for value of nested function call`() { val text = "hello .$ALIGN_FUNCTION {.func}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for value containing nested function call`() { val text = "hello .$ALIGN_FUNCTION {a .func b}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `chained calls should assign parameters correctly`() { val text = "hello .$ALIGN_FUNCTION {cen}::$CSV_FUNCTION {}" assertEquals(1, getDiagnostics(text).size) } } ================================================ FILE: quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/diagnostics/FunctionUnresolvedParameterNameDiagnosticsSupplierTest.kt ================================================ package com.quarkdown.lsp.diagnostics import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGNMENT_PARAMETER import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.ALIGN_FUNCTION import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.CSV_FUNCTION import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.DOCS_DIRECTORY import com.quarkdown.lsp.diagnostics.DiagnosticsTestUtils.PATH_PARAMETER import com.quarkdown.lsp.diagnostics.function.FunctionUnresolvedParameterNameDiagnosticsSupplier import org.eclipse.lsp4j.DiagnosticSeverity import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue private const val INVALID_PARAMETER = "invalid" /** * Tests for the diagnostics supplier for unresolved parameter names in function calls. */ class FunctionUnresolvedParameterNameDiagnosticsSupplierTest { private val supplier = FunctionUnresolvedParameterNameDiagnosticsSupplier(DOCS_DIRECTORY) private fun getDiagnostics(text: String): List<SimpleDiagnostic> = DiagnosticsTestUtils.getDiagnostics(text, supplier) @Test fun `no diagnostics in empty text`() { assertTrue(getDiagnostics("").isEmpty()) } @Test fun `no diagnostics in non-function text`() { val text = "hello world" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for unknown function`() { val text = "hello .myfunc param:{value}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `no diagnostics for valid parameter name`() { val text = "hello .$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{center}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `error for invalid parameter name`() { val text = "hello .$ALIGN_FUNCTION $INVALID_PARAMETER:{center}" val diagnostics = getDiagnostics(text) assertEquals(1, diagnostics.size) val diagnostic = diagnostics.single() assertEquals(DiagnosticSeverity.Error, diagnostic.severity) assertEquals(text.indexOf(INVALID_PARAMETER), diagnostic.range.start) assertEquals(text.indexOf(INVALID_PARAMETER) + INVALID_PARAMETER.length, diagnostic.range.endInclusive) } @Test fun `multiple diagnostics for multiple invalid parameter names`() { val text = "hello .$ALIGN_FUNCTION $INVALID_PARAMETER:{center} another:{value}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) } @Test fun `error for one invalid parameter name among valid ones`() { val text = "hello .$ALIGN_FUNCTION xyz:{} $ALIGNMENT_PARAMETER:{center} $INVALID_PARAMETER:{}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) } @Test fun `error in nested call`() { val text = "hello .func {.$ALIGN_FUNCTION $INVALID_PARAMETER:{}}" val diagnostics = getDiagnostics(text) assertEquals(1, diagnostics.size) } @Test fun `no diagnostics for positional parameters`() { val text = "hello .$ALIGN_FUNCTION {center}" assertTrue(getDiagnostics(text).isEmpty()) } @Test fun `chained calls should assign diagnose only for their own parameters`() { val text = "hello .$CSV_FUNCTION $PATH_PARAMETER:{arg}::$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{}" val diagnostics = getDiagnostics(text) assertTrue(diagnostics.isEmpty()) } @Test fun `chained calls should diagnose unresolved parameters`() { val text = "hello .$CSV_FUNCTION $PATH_PARAMETER:{arg} $INVALID_PARAMETER:{arg}::" + "$ALIGN_FUNCTION $ALIGNMENT_PARAMETER:{} $INVALID_PARAMETER:{}" val diagnostics = getDiagnostics(text) assertEquals(2, diagnostics.size) } } ================================================ FILE: quarkdown-lsp/src/test/resources/docs/com.quarkdown.stdlib.module.Data/csv.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>csv</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//csv/#com.quarkdown.core.context.Context#kotlin.String#kotlin.String?/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Data</a><span class="delimiter">/</span><span class="current">csv</span></div> <div class="cover "> <h1 class="cover"><span><span>csv</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">csv</span><span class="token punctuation"> </span><span class="token constant">path</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token constant">caption</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">a table whose content is loaded from the file located in <a href="csv.html">path</a></p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>path</span></span></u></div></span></div><div><div class="title"><p class="paragraph">path of the CSV file (with extension) to show</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>caption</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">caption of the table, if any</p></div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/File+data">File data</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-lsp/src/test/resources/docs/com.quarkdown.stdlib.module.Layout/align.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>align</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//align/#com.quarkdown.core.ast.quarkdown.block.Container.Alignment#com.quarkdown.core.ast.MarkdownContent/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Layout</a><span class="delimiter">/</span><span class="current">align</span></div> <div class="cover "> <h1 class="cover"><span><span>align</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">align</span><span class="token punctuation"> </span><span class="token constant">alignment</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment///PointingToDeclaration/">Container.Alignment</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token constant">body</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast/MarkdownContent///PointingToDeclaration/">MarkdownContent</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><p class="paragraph">Aligns content and text within its parent.</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">the new aligned <span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container///PointingToDeclaration/">Container</span> node</p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>alignment</span></span></u></div></span></div><div><div class="title"><p class="paragraph">content alignment anchor and text alignment</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>body</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__likely-body"></a>Likely a <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call#block-vs-inline-function-calls">body argument</a></li></ul></dl><p class="paragraph">content to center</p></div></div></div></div></div><h4 class="">See also</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><a href="container.html"><span><span>container</span></span></a></div></span></div><div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/Align">Align</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-lsp/src/test/resources/docs/com.quarkdown.stdlib.module.Layout/clip.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>clip</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//clip/#com.quarkdown.core.ast.quarkdown.block.Clipped.Clip#com.quarkdown.core.ast.MarkdownContent/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Layout</a><span class="delimiter">/</span><span class="current">clip</span></div> <div class="cover "> <h1 class="cover"><span><span>clip</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">clip</span><span class="token punctuation"> </span><span class="token constant">clip</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Clipped.Clip///PointingToDeclaration/">Clipped.Clip</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token constant">body</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast/MarkdownContent///PointingToDeclaration/">MarkdownContent</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><p class="paragraph">Applies a clipping path to its content.</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">the new <span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Clipped///PointingToDeclaration/">Clipped</span> block</p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>clip</span></span></u></div></span></div><div><div class="title"><p class="paragraph">clip type to apply</p><h4 class="">Values</h4><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Clipped.Clip.CIRCLE///PointingToDeclaration/"><code class="lang-kotlin">circle</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>body</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__likely-body"></a>Likely a <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call#block-vs-inline-function-calls">body argument</a></li></ul></dl><p class="paragraph">content to clip</p></div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/Clip">Clip</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-lsp/src/test/resources/docs/com.quarkdown.stdlib.module.Layout/column.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>column</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//column/#com.quarkdown.core.ast.quarkdown.block.Stacked.MainAxisAlignment#com.quarkdown.core.ast.quarkdown.block.Stacked.CrossAxisAlignment#com.quarkdown.core.document.size.Size?#com.quarkdown.core.ast.MarkdownContent/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Layout</a><span class="delimiter">/</span><span class="current">column</span></div> <div class="cover "> <h1 class="cover"><span><span>column</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">column</span><span class="token punctuation"> </span><span class="token constant">alignment</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment///PointingToDeclaration/">Stacked.MainAxisAlignment</span><span class="token operator"> = </span>Stacked.MainAxisAlignment.START<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">cross</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.CrossAxisAlignment///PointingToDeclaration/">Stacked.CrossAxisAlignment</span><span class="token operator"> = </span>Stacked.CrossAxisAlignment.CENTER<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">gap</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Size///PointingToDeclaration/">Size</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">body</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast/MarkdownContent///PointingToDeclaration/">MarkdownContent</span><span class="token punctuation">}</span><br><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><p class="paragraph">Stacks content vertically.</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">the new <span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked///PointingToDeclaration/">Stacked</span> node</p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>alignment</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">content alignment along the main axis</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.SPACE_BETWEEN///PointingToDeclaration/"><code class="lang-kotlin">spacebetween</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.SPACE_AROUND///PointingToDeclaration/"><code class="lang-kotlin">spacearound</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.MainAxisAlignment.SPACE_EVENLY///PointingToDeclaration/"><code class="lang-kotlin">spaceevenly</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>cross</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">content alignment along the cross axis</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.CrossAxisAlignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.CrossAxisAlignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.CrossAxisAlignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Stacked.CrossAxisAlignment.STRETCH///PointingToDeclaration/"><code class="lang-kotlin">stretch</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>gap</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">blank space between children. If omitted, the default value is used</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>body</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__likely-body"></a>Likely a <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call#block-vs-inline-function-calls">body argument</a></li></ul></dl><p class="paragraph">content to stack</p></div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/Stacks">Stacks</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-lsp/src/test/resources/html-to-markdown/align.html ================================================ <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">align</span><span class="token punctuation"> </span><span class="token constant">alignment</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment///PointingToDeclaration/">Container.Alignment</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token constant">body</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast/MarkdownContent///PointingToDeclaration/">MarkdownContent</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre> </div> <p class="paragraph">Aligns content and text within its parent.</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">the new aligned <span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container///PointingToDeclaration/">Container</span> node</p></span> <h4 class="">Parameters</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>alignment</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">content alignment anchor and text alignment</p><h4 class="">Values</h4> <ul> <li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li> <li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li> <li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li> </ul> </div> </div> </div> </div> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>body</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">content to center</p></div> </div> </div> </div> </div> <h4 class="">See also</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><a href="container.html"><span><span>container</span></span></a></div></span></div> <div></div> </div> </div> </div> <span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/Align">Align</a></span> </div> ================================================ FILE: quarkdown-lsp/src/test/resources/html-to-markdown/align.md ================================================ ```block lang-kotlin .align alignment:{Container.Alignment} body:{MarkdownContent} -> Node ``` Aligns content and text within its parent. #### Return the new aligned Container node #### Parameters * **alignment** content alignment anchor and text alignment Values * `start` * `center` * `end` * **body** content to center #### See also * [container](container.html) <br /> #### Wiki page [Align](https://github.com/iamgio/quarkdown/wiki/Align) ================================================ FILE: quarkdown-plaintext/README.md ================================================ # plain-text This module provides an extension to render Quarkdown ASTs to plain text (`.txt`). - `--render text` ================================================ FILE: quarkdown-plaintext/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation(kotlin("test")) implementation(project(":quarkdown-core")) } ================================================ FILE: quarkdown-plaintext/src/main/kotlin/com/quarkdown/rendering/plaintext/extension/PlainTextRendererExtension.kt ================================================ package com.quarkdown.rendering.plaintext.extension import com.quarkdown.core.context.Context import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.rendering.RenderingComponents import com.quarkdown.rendering.plaintext.node.PlainTextNodeRenderer import com.quarkdown.rendering.plaintext.post.PlainTextPostRenderer /** * The plain-text rendering plug-in produces a plain-text representation of the document. * It can be used for generating text-only versions of documents for accessibility or * for further processing by text-based tools. */ @Suppress("UnusedReceiverParameter") fun RendererFactory.plainText(context: Context) = RenderingComponents( nodeRenderer = PlainTextNodeRenderer(context), postRenderer = PlainTextPostRenderer(context), ) ================================================ FILE: quarkdown-plaintext/src/main/kotlin/com/quarkdown/rendering/plaintext/node/PlainTextNodeRenderer.kt ================================================ package com.quarkdown.rendering.plaintext.node import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.attributes.localization.LocalizedKind import com.quarkdown.core.ast.attributes.location.LocationTrackableNode import com.quarkdown.core.ast.attributes.location.getLocationLabel import com.quarkdown.core.ast.attributes.reference.getDefinition import com.quarkdown.core.ast.base.TextNode import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.FootnoteDefinition import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.block.Newline import com.quarkdown.core.ast.base.block.Paragraph import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.OrderedList import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.CriticalContent import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.ReferenceFootnote import com.quarkdown.core.ast.base.inline.ReferenceImage import com.quarkdown.core.ast.base.inline.ReferenceLink import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.quarkdown.CaptionableNode import com.quarkdown.core.ast.quarkdown.FunctionCallNode import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import com.quarkdown.core.ast.quarkdown.block.Landscape import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.SlidesSpeakerNote import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.block.toc.convertTableOfContentsToListNode import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.ast.quarkdown.inline.PageCounter import com.quarkdown.core.ast.quarkdown.inline.TextSymbol import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.Whitespace import com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer import com.quarkdown.core.ast.quarkdown.invisible.PageNumberFormatter import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.ast.quarkdown.reference.CrossReferenceableNode import com.quarkdown.core.bibliography.BibliographyEntry import com.quarkdown.core.context.Context import com.quarkdown.core.context.localization.localizeOrNull import com.quarkdown.core.rendering.NodeRenderer import com.quarkdown.core.util.indent /** * Node renderer that converts the AST to plain text. * It omits non-textual elements and formats structural elements appropriately. */ class PlainTextNodeRenderer( private val context: Context, ) : NodeRenderer { private fun NestableNode.visitChildren() = children.visitAll() private fun InlineContent.visitAll() = joinToString(separator = "") { it.accept(this@PlainTextNodeRenderer) } private val String.blockNode: String get() = when { endsWith("\n\n") -> this endsWith('\n') -> this + "\n" else -> this + "\n\n" } override fun visit(node: AstRoot) = node.visitChildren() override fun visit(node: Newline) = "" override fun visit(node: Code) = node.content.indent("\t").blockNode override fun visit(node: HorizontalRule) = "-----".blockNode override fun visit(node: Heading) = node.visitChildren().blockNode override fun visit(node: LinkDefinition) = "" override fun visit(node: FootnoteDefinition) = "" override fun visit(node: OrderedList) = buildString { node.children.forEachIndexed { index, item -> append(index + node.startIndex) append(". ") appendLine(item.accept(this@PlainTextNodeRenderer).trim()) if (node.isLoose) { appendLine() } } }.blockNode override fun visit(node: UnorderedList) = buildString { node.children.forEach { item -> append("- ") appendLine(item.accept(this@PlainTextNodeRenderer).trim()) if (node.isLoose) { appendLine() } } }.blockNode override fun visit(node: ListItem) = node.visitChildren().indent("\t") override fun visit(node: Html) = "" override fun visit(node: Table) = node.visitChildren().blockNode // Markdown-like table rendering may be supported later override fun visit(node: Paragraph) = node.visitChildren().blockNode override fun visit(node: BlockQuote) = "> ${node.visitChildren().trimEnd().replace("\n", "\n> ")}".blockNode override fun visit(node: BlankNode) = "" override fun visit(node: Comment) = "" override fun visit(node: LineBreak) = "\n" override fun visit(node: CriticalContent) = node.text override fun visit(node: Link) = node.visitChildren() override fun visit(node: ReferenceLink) = node.label.visitAll() override fun visit(node: SubdocumentLink) = visit(node.link) override fun visit(node: ReferenceFootnote) = "" // Footnotes are currently unsupported override fun visit(node: Image) = "" override fun visit(node: ReferenceImage) = "" override fun visit(node: CheckBox) = if (node.isChecked) "[x] " else "[ ] " override fun visit(node: Text) = node.text override fun visit(node: TextSymbol) = node.text override fun visit(node: CodeSpan) = node.text override fun visit(node: Emphasis) = node.visitChildren() override fun visit(node: Strong) = node.visitChildren() override fun visit(node: StrongEmphasis) = node.visitChildren() override fun visit(node: Strikethrough) = node.visitChildren() override fun visit(node: FunctionCallNode) = node.visitChildren() override fun visit(node: Figure<*>) = node.visitChildren() override fun visit(node: PageBreak) = "" override fun visit(node: Math) = node.expression.trim().blockNode override fun visit(node: Container) = node.visitChildren().blockNode override fun visit(node: Stacked) = node.visitChildren().blockNode override fun visit(node: Numbered) = node.visitChildren() override fun visit(node: Landscape) = node.visitChildren() override fun visit(node: Clipped) = node.visitChildren() override fun visit(node: Box) = ((node.title?.visitAll()?.plus("\n-----\n") ?: "") + node.visitChildren()).blockNode override fun visit(node: Collapse) = node.visitChildren() override fun visit(node: Whitespace) = "" override fun visit(node: NavigationContainer) = node.visitChildren() override fun visit(node: TableOfContentsView): CharSequence { val tableOfContents = context.attributes.tableOfContents ?: return "" val list = convertTableOfContentsToListNode( node, this@PlainTextNodeRenderer, tableOfContents.items, loose = false, wrapLinksInParagraphs = true, linkUrlMapper = { "" }, ) return list.accept(this).toString().blockNode } override fun visit(node: BibliographyView): CharSequence = buildString { node.bibliography.entries.values.forEachIndexed { index, entry -> append(node.style.labelProvider.getListLabel(entry, index)) append(" ") append( node.style .contentOf(entry) .visitAll(), ) appendLine() } }.blockNode override fun visit(node: MermaidDiagram) = "" override fun visit(node: FileTree): CharSequence { val list = buildBlock { unorderedList(loose = false) { node.entries.forEach { entry -> listItem { when (entry) { is FileTreeEntry.File -> { paragraph { text(entry.name) } } is FileTreeEntry.Directory -> { paragraph { text(entry.name + "/") } +FileTree(entry.entries) } is FileTreeEntry.Ellipsis -> { paragraph { text("...") } } } } } } } return list.accept(this).toString().blockNode } override fun visit(node: SubdocumentGraph) = "" override fun visit(node: MathSpan) = node.expression override fun visit(node: TextTransform) = node.visitChildren() override fun visit(node: IconImage) = "" override fun visit(node: InlineCollapse) = node.visitChildren() override fun visit(node: PageCounter) = "" override fun visit(node: LastHeading) = "" override fun visit(node: CrossReference): CharSequence { val definition: CrossReferenceableNode = node.getDefinition(context) ?: return Text("[???]").accept(this) val builder = StringBuilder() val content = when (definition) { is LocationTrackableNode if definition.getLocationLabel(context) != null -> { definition.getLocationLabel(context) } // If no label is available, use the caption if possible. is CaptionableNode if definition.caption != null -> { definition.caption!! } // Fallback: use the target's text if possible. is TextNode -> { definition.text } // Fallback: raw reference ID. else -> { node.referenceId } } if (definition is LocalizedKind) { val localizedKind = context.localizeOrNull(key = definition.kindLocalizationKey) localizedKind?.let(builder::append) builder.append(' ') } builder.append(content) return builder.toString() } override fun visit(node: BibliographyCitation): CharSequence { val (entries: List<BibliographyEntry>, view: BibliographyView) = node.getDefinition(context) ?: return "[???]" return view.style.labelProvider.getCitationLabel(entries) } override fun visit(node: SlidesFragment) = "" override fun visit(node: SlidesSpeakerNote) = "" override fun visit(node: PageMarginContentInitializer) = "" override fun visit(node: PageNumberFormatter) = "" override fun visit(node: PageNumberReset) = "" override fun visit(node: SlidesConfigurationInitializer) = "" } ================================================ FILE: quarkdown-plaintext/src/main/kotlin/com/quarkdown/rendering/plaintext/post/PlainTextPostRenderer.kt ================================================ package com.quarkdown.rendering.plaintext.post import com.quarkdown.core.context.Context import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.media.storage.options.MediaStorageOptions import com.quarkdown.core.media.storage.options.ReadOnlyMediaStorageOptions import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.core.pipeline.output.visitor.copy import com.quarkdown.core.rendering.PostRenderer /** * Post-renderer that generates plain-text output artifacts. * * - Produces a single plain-text file if there is only one subdocument. * - Produces a resource group of plain-text files if there are multiple subdocuments. */ class PlainTextPostRenderer( private val context: Context, ) : PostRenderer { override val preferredMediaStorageOptions: MediaStorageOptions get() = ReadOnlyMediaStorageOptions() override fun wrap(content: CharSequence): CharSequence = content override fun generateResources(rendered: CharSequence): Set<OutputResource> = setOf( TextOutputArtifact( name = context.subdocument.getOutputFileName(context), content = rendered.trimEnd(), type = ArtifactType.PLAIN_TEXT, ), ) override fun wrapResources( name: String, resources: Set<OutputResource>, ): OutputResource { // Single output file. resources.singleOrNull()?.let { return it.copy(name = name) } // Multiple output files. return OutputResourceGroup( name = name, resources = resources, ) } } ================================================ FILE: quarkdown-plaintext/src/test/kotlin/com/quarkdown/rendering/plaintext/PlainTextNodeRendererTest.kt ================================================ package com.quarkdown.rendering.plaintext import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.BlockQuote import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.HorizontalRule import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.ast.base.block.LinkDefinition import com.quarkdown.core.ast.base.inline.CheckBox import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.Comment import com.quarkdown.core.ast.base.inline.Emphasis import com.quarkdown.core.ast.base.inline.Image import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.Strikethrough import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildBlock import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import com.quarkdown.core.ast.quarkdown.block.Math import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.inline.MathSpan import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.toc.TableOfContents import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.rendering.plaintext.node.PlainTextNodeRenderer import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [PlainTextNodeRenderer]. */ class PlainTextNodeRendererTest { private fun Node.render(context: Context = MutableContext(QuarkdownFlavor)) = this.accept(PlainTextNodeRenderer(context)) @Test fun `ast root, single child`() { // Trailing newlines are trimmed later in post-rendering. assertEquals( "Hello, Quarkdown!\n\n", AstRoot( buildBlocks { paragraph { text("Hello, Quarkdown!") } }, ).render(), ) } @Test fun `ast root, multiple children`() { assertEquals( "Hello, Quarkdown!\n\nLearn more on GitHub.\n\n", AstRoot( buildBlocks { paragraph { text("Hello, Quarkdown!") } paragraph { text("Learn more on GitHub.") } }, ).render(), ) } @Test fun `code block`() { assertEquals( "\tfun main() {\n\t println(\"Hello, Quarkdown!\")\n\t}\n\n", Code( language = "kotlin", content = "fun main() {\n println(\"Hello, Quarkdown!\")\n}", ).render(), ) } @Test fun `horizontal rule`() { assertEquals( "-----\n\n", HorizontalRule.render(), ) } @Test fun heading() { assertEquals( "Hello\n\n", Heading( depth = 1, text = buildInline { text("Hello") }, ).render(), ) } @Test fun `link definition`() { assertEquals( "", LinkDefinition( label = buildInline { text("example") }, url = "https://example.com", title = "Example", ).render(), ) } @Test fun `ordered list, tight`() { assertEquals( "1. First\n2. Second\n3. Third\n\n", buildBlocks { orderedList(loose = false) { listItem { paragraph { text("First") } } listItem { paragraph { text("Second") } } listItem { paragraph { text("Third") } } } }.first().render(), ) } @Test fun `ordered list, loose`() { assertEquals( "1. First\n\n2. Second\n\n3. Third\n\n", buildBlocks { orderedList(loose = true) { listItem { paragraph { text("First") } } listItem { paragraph { text("Second") } } listItem { paragraph { text("Third") } } } }.first().render(), ) } @Test fun `ordered list, nested`() { assertEquals( "1. Item 1\n2. Item 2\n\t1. Subitem 2a\n\t2. Subitem 2b\n3. Item 3\n\n", buildBlock { orderedList(loose = false) { listItem { paragraph { text("Item 1") } } listItem { paragraph { text("Item 2") } orderedList(loose = false) { listItem { paragraph { text("Subitem 2a") } } listItem { paragraph { text("Subitem 2b") } } } } listItem { paragraph { text("Item 3") } } } }.render(), ) } @Test fun `unordered list, tight`() { assertEquals( "- First\n- Second\n- Third\n\n", buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("First") } } listItem { paragraph { text("Second") } } listItem { paragraph { text("Third") } } } }.first().render(), ) } @Test fun `unordered list, loose`() { assertEquals( "- First\n\n- Second\n\n- Third\n\n", buildBlocks { unorderedList(loose = true) { listItem { paragraph { text("First") } } listItem { paragraph { text("Second") } } listItem { paragraph { text("Third") } } } }.first().render(), ) } @Test fun `unordered list, nested`() { assertEquals( "- Item 1\n\t- Subitem 1a\n\t- Subitem 1b\n- Item 2\n\n", buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("Item 1") } unorderedList(loose = false) { listItem { paragraph { text("Subitem 1a") } } listItem { paragraph { text("Subitem 1b") } } } } listItem { paragraph { text("Item 2") } } } }.first().render(), ) } @Test fun html() { assertEquals( "", Html("<div>Hello</div>").render(), ) } @Test fun `block quote`() { assertEquals( "> Hello\n\n", BlockQuote( children = buildBlocks { paragraph { text("Hello") } }, ).render(), ) } @Test fun `block quote multiline`() { assertEquals( "> Hello\n> \n> \tWorld\n> \t!\n\n", BlockQuote( children = buildBlocks { paragraph { text("Hello") } +Code(language = null, content = "World\n!") }, ).render(), ) } @Test fun comment() { assertEquals( "", Comment.render(), ) } @Test fun `line break`() { assertEquals( "\n", LineBreak.render(), ) } @Test fun link() { assertEquals( "Click here", Link( url = "https://example.com", title = null, label = buildInline { text("Click here") }, ).render(), ) } @Test fun image() { assertEquals( "", Image( link = Link( url = "https://example.com/image.png", title = null, label = buildInline { text("Alt text") }, ), width = null, height = null, ).render(), ) } @Test fun checkbox() { assertEquals("[x] ", CheckBox(isChecked = true).render()) assertEquals("[ ] ", CheckBox(isChecked = false).render()) } @Test fun text() { assertEquals( "Hello, Quarkdown!", Text("Hello, Quarkdown!").render(), ) } @Test fun `code span`() { assertEquals( "println()", CodeSpan("println()").render(), ) } @Test fun emphasis() { assertEquals( "Hello", Emphasis(buildInline { text("Hello") }).render(), ) } @Test fun strong() { assertEquals( "Hello", Strong(buildInline { text("Hello") }).render(), ) } @Test fun `strong emphasis`() { assertEquals( "Hello", StrongEmphasis(buildInline { text("Hello") }).render(), ) } @Test fun strikethrough() { assertEquals( "Hello", Strikethrough(buildInline { text("Hello") }).render(), ) } @Test fun math() { assertEquals( "x^2 + y^2 = z^2\n\n", Math("x^2 + y^2 = z^2").render(), ) } @Test fun `math span`() { assertEquals( "x^2", MathSpan("x^2").render(), ) } @Test fun `box without title`() { assertEquals( "Hello\n\n", Box( title = null, type = Box.Type.CALLOUT, children = buildBlocks { paragraph { text("Hello") } }, ).render(), ) } @Test fun `box with title`() { assertEquals( "Note\n-----\nHello\n\n", Box( title = buildInline { text("Note") }, type = Box.Type.CALLOUT, children = buildBlocks { paragraph { text("Hello") } }, ).render(), ) } @Test fun `file tree, files only`() { assertEquals( "- file1.txt\n- file2.json\n\n", FileTree( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.json"), ), ).render(), ) } @Test fun `file tree, directory with files`() { assertEquals( "- src/\n\t- main.ts\n\t- utils.ts\n- README.md\n\n", FileTree( listOf( FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts"), FileTreeEntry.File("utils.ts"), ), ), FileTreeEntry.File("README.md"), ), ).render(), ) } @Test fun `file tree, highlighted entries`() { assertEquals( "- file1.txt\n- file2.txt\n- src/\n\t- main.ts\n\t- utils.ts\n\n", FileTree( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.txt", highlighted = true), FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts", highlighted = true), FileTreeEntry.File("utils.ts"), ), highlighted = true, ), ), ).render(), ) } @Test fun `file tree, ellipsis`() { assertEquals( "- index.ts\n- ...\n\n", FileTree( listOf( FileTreeEntry.File("index.ts"), FileTreeEntry.Ellipsis(), ), ).render(), ) } @Test fun `table of contents`() { val toc = TableOfContents( items = listOf( TableOfContents.Item( heading = Heading( depth = 1, text = buildInline { text("Welcome") }, ), subItems = emptyList(), ), TableOfContents.Item( heading = Heading( depth = 2, text = buildInline { text("Introduction") }, ), subItems = listOf( TableOfContents.Item( heading = Heading( depth = 3, text = buildInline { text("Getting Started") }, ), subItems = emptyList(), ), ), ), ), ) val context = MutableContext(QuarkdownFlavor) context.attributes.tableOfContents = toc assertEquals( "Table of Contents\n\n" + "1. Welcome\n2. Introduction\n\t1. Getting Started\n\n", AstRoot( listOf( Heading(depth = 1, text = buildInline { text("Table of Contents") }), TableOfContentsView(maxDepth = 3), ), ).render(context), ) } } ================================================ FILE: quarkdown-plaintext/src/test/kotlin/com/quarkdown/rendering/plaintext/PlainTextPostRendererTest.kt ================================================ package com.quarkdown.rendering.plaintext import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.pipeline.output.ArtifactType import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.rendering.plaintext.post.PlainTextPostRenderer import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs /** * Tests for [PlainTextPostRenderer]. */ class PlainTextPostRendererTest { @Test fun `resource generation`() { val postRenderer = PlainTextPostRenderer(MutableContext(QuarkdownFlavor)) val resources = postRenderer.generateResources("Hello, World!\n\n") val resource = resources.single() assertIs<TextOutputArtifact>(resource) assertEquals("Hello, World!", resource.content) } @Test fun `single resource wrapping`() { val postRenderer = PlainTextPostRenderer(MutableContext(QuarkdownFlavor)) val resource = postRenderer.wrapResources( name = "Hello", resources = setOf( TextOutputArtifact( name = "output", content = "Content", type = ArtifactType.PLAIN_TEXT, ), ), ) assertIs<TextOutputArtifact>(resource) assertEquals("Hello", resource.name) } @Test fun `multiple resource wrapping`() { val postRenderer = PlainTextPostRenderer(MutableContext(QuarkdownFlavor)) val resource = postRenderer.wrapResources( name = "Group", resources = setOf( TextOutputArtifact( name = "output1", content = "Content 1", type = ArtifactType.PLAIN_TEXT, ), TextOutputArtifact( name = "output2", content = "Content 2", type = ArtifactType.PLAIN_TEXT, ), ), ) assertEquals("Group", resource.name) val group = assertIs<OutputResourceGroup>(resource) assertEquals(2, group.resources.size) } } ================================================ FILE: quarkdown-quarkdoc/README.md ================================================ # quarkdoc This module contains the Quarkdoc plugin for [Dokka](https://github.com/Kotlin/dokka), the Kotlin documentation engine. Quarkdoc extends Dokka by providing Quarkdown-level documentation for native libraries, i.e. collections of strongly-typed Quarkdown functions written in Kotlin. When a module adopts this plugin, its benefits include: - Quarkdown-syntax function signatures; - Package generation from Quarkdown modules: in a native Quarkdown library, a `Module` is a single source file. The plugin generates a pseudo-package for each module, making it easier to navigate library functions by module name; - Function/parameter name adaptation via `@Name` (Quarkdown's functions don't always match their native signature); - Listing of enum entries for enum-type function parameters; - Function's document type constraints via `@OnlyForDocumentType`/`@NotForDocumentType`; - Suppression of `@Injected` function parameters; To see all enhancements, [`QuarkdocDokkaPlugin`](src/main/kotlin/com/quarkdown/quarkdoc/dokka/QuarkdocDokkaPlugin.kt) features a complete list. ================================================ FILE: quarkdown-quarkdoc/build.gradle.kts ================================================ extra["noRuntime"] = true plugins { kotlin("jvm") } dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") implementation(project(":quarkdown-core")) implementation(project(":quarkdown-quarkdoc-reader")) implementation(kotlin("reflect")) val dokkaVersion = "2.0.0" compileOnly("org.jetbrains.dokka:dokka-core:$dokkaVersion") implementation("org.jetbrains.dokka:dokka-base:$dokkaVersion") testImplementation("org.jetbrains.dokka:dokka-test-api:$dokkaVersion") testImplementation("org.jetbrains.dokka:dokka-base-test-utils:$dokkaVersion") testRuntimeOnly("org.jetbrains.dokka:analysis-kotlin-symbols:$dokkaVersion") testImplementation("org.jsoup:jsoup:1.21.2") } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/QuarkdocDokkaPlugin.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.quarkdoc.dokka.page.DocumentTypeConstraintsPageTransformer import com.quarkdown.quarkdoc.dokka.page.LikelyChainedPageTransformer import com.quarkdown.quarkdoc.dokka.page.WikiLinkPageTransformer import com.quarkdown.quarkdoc.dokka.signature.QuarkdownSignatureProvider import com.quarkdown.quarkdoc.dokka.transformers.enumeration.EnumParameterEntryListerTransformer import com.quarkdown.quarkdoc.dokka.transformers.enumeration.EnumStorer import com.quarkdown.quarkdoc.dokka.transformers.misc.DocumentTypeConstraintsTransformer import com.quarkdown.quarkdoc.dokka.transformers.module.ModuleAsPackageTransformer import com.quarkdown.quarkdoc.dokka.transformers.module.ModulesStorer import com.quarkdown.quarkdoc.dokka.transformers.name.DocumentableNameTransformer import com.quarkdown.quarkdoc.dokka.transformers.name.DocumentationNameTransformer import com.quarkdown.quarkdoc.dokka.transformers.name.RenamingsStorer import com.quarkdown.quarkdoc.dokka.transformers.optional.AdditionalParameterPropertiesTransformer import com.quarkdown.quarkdoc.dokka.transformers.suppress.SuppressInjectedTransformer import com.quarkdown.quarkdoc.dokka.transformers.type.ValueTypeTransformer import org.jetbrains.dokka.CoreExtensions import org.jetbrains.dokka.base.DokkaBase import org.jetbrains.dokka.plugability.DokkaPlugin import org.jetbrains.dokka.plugability.DokkaPluginApiPreview import org.jetbrains.dokka.plugability.PluginApiPreviewAcknowledgement /** * Dokka plugin that generates ad-hoc documentation for native Quarkdown functions. */ @Suppress("unused") class QuarkdocDokkaPlugin : DokkaPlugin() { private val base by lazy { plugin<DokkaBase>() } /** * Stores the modules in which the functions are declared, to be used in [moduleAsPackageTransformer]. * @see com.quarkdown.core.function.library.loader.Module */ val modulesStorer by extending { base.preMergeDocumentableTransformer providing ::ModulesStorer order { before(moduleAsPackageTransformer) } } /** * Quarkdown modules, defined by a [com.quarkdown.core.function.library.loader.Module] property, * contain the functions declared in the same source file and are shown in the documentation as packages. * @see com.quarkdown.core.function.library.loader.Module */ val moduleAsPackageTransformer by extending { base.preMergeDocumentableTransformer providing ::ModuleAsPackageTransformer } /** * Stores the old-new function name pairs, to be used in [documentableNameTransformer] and [documentationNameTransformer]. * @see com.quarkdown.core.function.reflect.annotation.Name */ val renamingsStorer by extending { plugin<DokkaBase>().preMergeDocumentableTransformer providing ::RenamingsStorer order { before(documentableNameTransformer, documentationNameTransformer) } } /** * Functions and parameters annotated with `@Name` are renamed in the function signature. * @see com.quarkdown.core.function.reflect.annotation.Name */ val documentableNameTransformer by extending { base.preMergeDocumentableTransformer providing ::DocumentableNameTransformer } /** * Functions and parameters annotated with `@Name` are renamed in the documentation. * This includes: * - Direct links (`[name]`) * - Parameter (`@param name`) * - See references (`@see name`) * @see com.quarkdown.core.function.reflect.annotation.Name */ val documentationNameTransformer by extending { base.preMergeDocumentableTransformer providing ::DocumentationNameTransformer } /** * Renames references of [com.quarkdown.core.function.value.Value], and subclasses, in the signature * to a more human-readable form. */ val valueTypeTransformer by extending { base.preMergeDocumentableTransformer providing ::ValueTypeTransformer } /** * Parameters annotated with `@Injected` are hidden (suppressed) in the generated documentation. * @see com.quarkdown.core.function.reflect.annotation.Injected */ val suppressInjectedTransformer by extending { base.preMergeDocumentableTransformer providing ::SuppressInjectedTransformer } /** * Stores enum declarations, to be used in [enumParameterEntryListerTransformer]. */ val enumStorer by extending { base.preMergeDocumentableTransformer providing ::EnumStorer order { before(enumParameterEntryListerTransformer) } } /** * Lists enum entries in the documentation for parameters that expect an enum. */ val enumParameterEntryListerTransformer by extending { base.preMergeDocumentableTransformer providing ::EnumParameterEntryListerTransformer } val additionalParameterPropertiesTransformer by extending { base.preMergeDocumentableTransformer providing ::AdditionalParameterPropertiesTransformer } /** * Given a function annotated with `@OnlyForDocumentType` which defines constraints * about the document type the function supports, this transformer stores this data * for [documentTypeConstraintsPageTransformer] to display it. * @see com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType */ val documentPositiveTypeConstraintsTransformer by extending { base.preMergeDocumentableTransformer providing DocumentTypeConstraintsTransformer::Positive } /** * Like [documentPositiveTypeConstraintsTransformer] but for the negative case, via `@NotForDocumentType`. * @see com.quarkdown.core.function.reflect.annotation.NotForDocumentType */ val documentNegativeTypeConstraintsTransformer by extending { base.preMergeDocumentableTransformer providing DocumentTypeConstraintsTransformer::Negative } /** * Displays the document type constraints produced by [documentTypeConstraintsTransformer] in the documentation. */ val documentTypeConstraintsPageTransformer by extending { CoreExtensions.pageTransformer providing ::DocumentTypeConstraintsPageTransformer } /** * Generates a new section for likely chained functions. */ val likelyChainedPageTransformer by extending { CoreExtensions.pageTransformer providing ::LikelyChainedPageTransformer } /** * Generates a new section for the `@wiki` documentation tag with a link to the corresponding wiki page. */ val wikiLinkPageTransformer by extending { CoreExtensions.pageTransformer providing ::WikiLinkPageTransformer order { after(likelyChainedPageTransformer) } } /** * Generates Quarkdown signatures for functions in Quarkdown modules. */ val signatureProvider by extending { base.signatureProvider providing ::QuarkdownSignatureProvider override base.kotlinSignatureProvider } @DokkaPluginApiPreview override fun pluginApiPreviewAcknowledgement() = PluginApiPreviewAcknowledgement } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/DeepDocumentationMapper.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc import com.quarkdown.quarkdoc.dokka.util.tryCopy import org.jetbrains.dokka.model.WithChildren import org.jetbrains.dokka.model.doc.DocTag import kotlin.reflect.KClass /** * An implementation of [DocumentationMapper] that performs a deep mapping of documentation nodes down the node tree. */ class DeepDocumentationMapper : DocumentationMapper { private val simple = SimpleDocumentationMapper() override fun <T : WithChildren<*>> register( nodeType: KClass<T>, mapper: (T) -> WithChildren<*>, ): DocumentationMapper = apply { simple.register(nodeType, mapper) } @Suppress("UNCHECKED_CAST") override fun map(node: WithChildren<*>): WithChildren<*> = simple.map(node).let { mapped -> val children = mapped.children as List<WithChildren<*>> mapped.tryCopy(newChildren = children.map(::map) as List<DocTag>) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/DocTagDsl.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.doc.A import org.jetbrains.dokka.model.doc.CodeInline import org.jetbrains.dokka.model.doc.DocTag import org.jetbrains.dokka.model.doc.DocumentationLink import org.jetbrains.dokka.model.doc.H1 import org.jetbrains.dokka.model.doc.H2 import org.jetbrains.dokka.model.doc.H3 import org.jetbrains.dokka.model.doc.H4 import org.jetbrains.dokka.model.doc.Li import org.jetbrains.dokka.model.doc.Text import org.jetbrains.dokka.model.doc.Ul /** * A DSL for building [DocTag]s, which are used to represent documentation content in Dokka. * This DSL allows for a more readable and structured way to create documentation tags. */ class DocTagBuilder { private val tags = mutableListOf<DocTag>() private fun add(tag: DocTag) { tags.add(tag) } private operator fun DocTag.unaryPlus() { add(this) } private fun build(block: DocTagBuilder.() -> Unit): List<DocTag> = buildDocTags(block) /** * @see Text */ fun text(text: String) { +Text(text) } /** * @see CodeInline */ fun codeInline(text: String) { +CodeInline(children = listOf(Text(text))) } /** * @see H1 */ fun h1(block: DocTagBuilder.() -> Unit) { +H1(children = build(block)) } /** * @see H2 */ fun h2(block: DocTagBuilder.() -> Unit) { +H2(children = build(block)) } /** * @see H3 */ fun h3(block: DocTagBuilder.() -> Unit) { +H3(children = build(block)) } /** * @see H4 */ fun h4(block: DocTagBuilder.() -> Unit) { +H4(children = build(block)) } /** * @see A */ fun link( address: String, block: DocTagBuilder.() -> Unit, ) { +A( params = mapOf("href" to address), children = build(block), ) } /** * @see A */ fun link( address: String, text: String, ) { link(address) { text(text) } } /** * @see DocumentationLink */ fun link( dri: DRI, block: DocTagBuilder.() -> Unit, ) { +DocumentationLink( dri = dri, children = build(block), ) } /** * @see Ul */ fun unorderedList(block: DocTagBuilder.() -> Unit) { +Ul(children = build(block)) } /** * @see Li */ fun listItem(block: DocTagBuilder.() -> Unit) { +Li(children = build(block)) } /** * @return the built tags */ fun build(): List<DocTag> = tags.toList() } /** * Builds a list of [DocTag]s using the provided [block] via a [DocTagBuilder] DSL. * This is a convenient function to create documentation tags in a more readable way. * @param block the DSL block to build the tags * @return a list of [DocTag]s created by the DSL block */ fun buildDocTags(block: DocTagBuilder.() -> Unit): List<DocTag> = DocTagBuilder().apply(block).build() ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/DocumentationMapper.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc import org.jetbrains.dokka.DokkaConfiguration import org.jetbrains.dokka.model.WithChildren import org.jetbrains.dokka.model.doc.DocumentationNode import kotlin.reflect.KClass typealias DokkaDocumentation = Map<DokkaConfiguration.DokkaSourceSet, DocumentationNode> /** * A fluent mapper for transforming documentation nodes. */ interface DocumentationMapper { /** * Registers a mapper for a specific type of documentation node. * @param nodeType the type of the documentation node to register the mapper for * @param mapper the mapper function to apply to the documentation node * @return this instance for chaining */ fun <T : WithChildren<*>> register( nodeType: KClass<T>, mapper: (T) -> WithChildren<*>, ): DocumentationMapper /** * Maps a documentation node using the registered mappers. * @param node the documentation node to map * @return the mapped documentation node */ fun map(node: WithChildren<*>): WithChildren<*> /** * Maps a full documentation using the registered mappers. * @param documentation the documentation node to map * @return the mapped documentation */ fun map(documentation: DokkaDocumentation): DokkaDocumentation = documentation .mapValues { (_, node) -> map(node) as DocumentationNode }.toMap() } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/DocumentationMapperDsl.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc /** * DSL for [DocumentationMapper]. */ fun mapDocumentation( documentation: DokkaDocumentation, deep: Boolean = true, block: DocumentationMapper.() -> Unit, ): DokkaDocumentation { val mapper = if (deep) { DeepDocumentationMapper() } else { SimpleDocumentationMapper() } return mapper.also(block).map(documentation) } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/DocumentationReferencesTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.doc.DocumentationLink import org.jetbrains.dokka.model.doc.Param import org.jetbrains.dokka.model.doc.See /** * Entity that transforms references of [org.jetbrains.dokka.model.Documentable]s within a KDoc documentation. */ interface DocumentationReferencesTransformer { /** * Transforms a link, in the form of `[link]`, within a KDoc documentation. * @param the original link * @return the transformed link */ fun onLink(link: DocumentationLink): DocumentationLink /** * Transforms a parameter reference, in the form of `@param paramName`, within a KDoc documentation. * @param param the original parameter reference * @param actualParameter the parameter that the reference points to * @return the transformed parameter reference */ fun onParam( param: Param, actualParameter: DParameter, ): Param /** * Transforms a "see" reference, in the form of `@see link`, within a KDoc documentation. * @param see the original "see" reference * @return the transformed "see" reference */ fun onSee(see: See): See /** * Transforms references within a KDoc documentation. * @param documentation the original documentation * @param parameters the list of arameters to be looked up from `@param` references * @return the transformed documentation */ fun transformReferences( documentation: DokkaDocumentation, parameters: List<DParameter>, ): DokkaDocumentation = mapDocumentation(documentation) { register(DocumentationLink::class, ::onLink) register(Param::class) { param -> val actualParameter = parameters.find { it.name == param.name } ?: return@register param onParam(param, actualParameter) } register(See::class, ::onSee) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/kdoc/SimpleDocumentationMapper.kt ================================================ package com.quarkdown.quarkdoc.dokka.kdoc import org.jetbrains.dokka.model.WithChildren import kotlin.reflect.KClass /** * A simple implementation of [DocumentationMapper] that performs a shallow mapping of documentation nodes. */ class SimpleDocumentationMapper : DocumentationMapper { private val mappers = mutableMapOf<KClass<out WithChildren<*>>, (WithChildren<*>) -> WithChildren<*>>() @Suppress("UNCHECKED_CAST") override fun <T : WithChildren<*>> register( nodeType: KClass<T>, mapper: (T) -> WithChildren<*>, ): DocumentationMapper = apply { mappers[nodeType] = mapper as (WithChildren<*>) -> WithChildren<*> } override fun map(node: WithChildren<*>): WithChildren<*> = mappers[node::class]?.let { mapper -> mapper(node) } ?: node } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/page/DocumentTypeConstraintsPageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.page import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.quarkdownName import com.quarkdown.quarkdoc.dokka.transformers.misc.DocumentTargetProperty import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.pages.TextStyle import org.jetbrains.dokka.plugability.DokkaContext /** * Given a function that is constrained to specific document types, * this page transformer explains these constraints in a new section of the documentation page. * @see com.quarkdown.quarkdoc.dokka.transformers.misc.DocumentTypeConstraintsTransformer */ class DocumentTypeConstraintsPageTransformer( context: DokkaContext, ) : NewSectionDocumentablePageTransformer<DFunction, List<DocumentType>>("Target", context) { override fun extractDocumentable(documentables: List<Documentable>) = documentables.firstOrNull() as? DFunction override fun extractData(documentable: DFunction): List<DocumentType>? = documentable.extra[DocumentTargetProperty]?.targets override fun createSection( data: List<DocumentType>, documentable: DFunction, builder: PageContentBuilder.DocumentableContentBuilder, ) = builder.buildGroup { text("This function is ") text("only available", styles = setOf(TextStyle.Bold)) text(" for the following document types: ") data.forEachIndexed { index, target -> codeInline { text(target.quarkdownName) } text(if (index < data.size - 1) ", " else ".") } } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/page/DocumentablePageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.page import com.quarkdown.quarkdoc.dokka.util.documentableContentBuilder import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.pages.ContentNode import org.jetbrains.dokka.pages.ContentPage import org.jetbrains.dokka.pages.RootPageNode import org.jetbrains.dokka.pages.WithDocumentables import org.jetbrains.dokka.plugability.DokkaContext import org.jetbrains.dokka.transformers.pages.PageTransformer /** * A [PageTransformer] that features a ready-to-use content builder for building documentation pages for [Documentable]s of type [D]. * @param D the type of [Documentable] that should own the page */ abstract class DocumentablePageTransformer<D : Documentable>( private val context: DokkaContext, ) : PageTransformer { /** * Extracts the [Documentable] from the list of documentables featured in the page. * @param documentables the list of documentables of the page * @return the extracted [Documentable] of type [D], if any. Transformation is stopped if `null`. */ protected abstract fun extractDocumentable(documentables: List<Documentable>): D? /** * Creates the content for the page based on the extracted documentable via [extractDocumentable]. * @param page the page to create content for * @param documentable the [Documentable] of type [D] that owns the page * @param builder the content builder to use * @return the created content, if any. Transformation is stopped if `null`. */ protected abstract fun createContent( page: ContentPage, documentable: D, builder: PageContentBuilder.DocumentableContentBuilder, ): ContentNode? override fun invoke(input: RootPageNode): RootPageNode { return input.transformContentPagesTree { page -> if (page !is WithDocumentables) return@transformContentPagesTree page val documentable = extractDocumentable(page.documentables) ?: return@transformContentPagesTree page val builder = context.documentableContentBuilder( documentable, page.dri, ) createContent(page, documentable, builder) ?.let { page.modified(content = it) } ?: page } } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/page/LikelyChainedPageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.page import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.quarkdoc.dokka.signature.QuarkdownSignatureProvider import com.quarkdown.quarkdoc.dokka.util.hasAnnotation import com.quarkdown.quarkdoc.dokka.util.scrapingAnchor import com.quarkdown.quarkdoc.reader.anchors.Anchors import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.plugability.DokkaContext private const val CHAINING_WIKI_PAGE = "syntax-of-a-function-call#chaining-calls" /** * Transformer that generates a new section for the `@wiki` documentation tag of a function, * with a link to the corresponding wiki page. */ class LikelyChainedPageTransformer( context: DokkaContext, ) : NewSectionDocumentablePageTransformer<DFunction, Boolean>("Chaining", context) { private val signatureProvider by lazy { QuarkdownSignatureProvider( context, requireModule = false, defaultValues = false, withChaining = true, ) } override fun extractDocumentable(documentables: List<Documentable>) = documentables.firstOrNull() as? DFunction override fun extractData(documentable: DFunction): Boolean? = true .takeIf { documentable.hasAnnotation<LikelyChained>() } ?.takeIf { documentable.parameters.isNotEmpty() } override fun createSection( data: Boolean, documentable: DFunction, builder: PageContentBuilder.DocumentableContentBuilder, ) = builder.buildGroup { scrapingAnchor(Anchors.LIKELY_CHAINED) text("This function is designed to be ") link( "chained", WIKI_ROOT + CHAINING_WIKI_PAGE, ) text(" with other function calls:") +signatureProvider.signature(documentable) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/page/NewSectionDocumentablePageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.page import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.pages.ContentDivergentInstance import org.jetbrains.dokka.pages.ContentGroup import org.jetbrains.dokka.pages.ContentNode import org.jetbrains.dokka.pages.ContentPage import org.jetbrains.dokka.pages.ContentStyle import org.jetbrains.dokka.pages.recursiveMapTransform import org.jetbrains.dokka.plugability.DokkaContext private const val KDOC_TAG_HEADER_LEVEL = 4 /** * A [org.jetbrains.dokka.transformers.pages.PageTransformer] that creates a new titled section in the documentation page of a [Documentable]. * For instance, *Parameters*, *Return*, *See also*, etc. are titled sections. * @param D the type of [Documentable] that owns the page * @param T the type of data to be extracted from the [Documentable] and displayed in the section */ abstract class NewSectionDocumentablePageTransformer<D : Documentable, T>( private val title: String, context: DokkaContext, ) : DocumentablePageTransformer<D>(context) { /** * Extracts the data from the [Documentable] to be displayed in the section content. * @return the extracted data, if any. Transformation is stopped if `null`. */ protected abstract fun extractData(documentable: D): T? /** * Creates the content for the section based on the data extracted from [extractData]. * @param data the extracted data * @param documentable the [Documentable] that owns the page * @param builder the content builder to use * @return the created content, without the header */ protected abstract fun createSection( data: T, documentable: D, builder: PageContentBuilder.DocumentableContentBuilder, ): ContentNode /** * Prepends a section header to the content created by [createSection]. */ private fun createTitledSection( data: T, documentable: D, builder: PageContentBuilder.DocumentableContentBuilder, ): ContentNode = builder.buildGroup(styles = setOf(ContentStyle.KDocTag)) { header(level = KDOC_TAG_HEADER_LEVEL, title) +createSection(data, documentable, builder).children } override fun createContent( page: ContentPage, documentable: D, builder: PageContentBuilder.DocumentableContentBuilder, ): ContentNode? { val data = extractData(documentable) ?: return null val section = createTitledSection(data, documentable, builder) // The original page content to update lies in a ContentDivergentInstance node. return page.content.recursiveMapTransform<ContentDivergentInstance, ContentNode> { node -> when (val after = node.after) { is ContentGroup -> node.copy(after = after.copy(children = after.children + listOf(section))) null -> node.copy(after = section) else -> node } } } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/page/WikiLinkPageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.page import com.quarkdown.quarkdoc.dokka.util.findDeep import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.model.doc.CustomTagWrapper import org.jetbrains.dokka.model.doc.Text import org.jetbrains.dokka.plugability.DokkaContext import java.net.URLEncoder private const val TAG_NAME = "wiki" /** * The root URL of the Quarkdown wiki to link to. */ const val WIKI_ROOT = "https://quarkdown.com/wiki/" /** * Transformer that generates a new section for the `@wiki` documentation tag of a function, * with a link to the corresponding wiki page. */ class WikiLinkPageTransformer( context: DokkaContext, ) : NewSectionDocumentablePageTransformer<DFunction, String>("Wiki page", context) { override fun extractDocumentable(documentables: List<Documentable>) = documentables.firstOrNull() as? DFunction /** * Extracts the wiki page name from the `@wiki` documentation tag, if present. * For example: `@wiki home` -> `home` */ override fun extractData(documentable: DFunction): String? { val wikiTag: CustomTagWrapper = documentable.documentation.values .firstOrNull() ?.findDeep<CustomTagWrapper> { it.name == TAG_NAME } ?: return null return wikiTag.root.findDeep<Text>()?.body } override fun createSection( data: String, documentable: DFunction, builder: PageContentBuilder.DocumentableContentBuilder, ) = builder.buildGroup { val url = WIKI_ROOT + URLEncoder.encode(data, Charsets.UTF_8) link(data, url) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/signature/KotlinSignatureReflectionHelper.kt ================================================ package com.quarkdown.quarkdoc.dokka.signature import org.jetbrains.dokka.base.signatures.KotlinSignatureProvider import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.Projection import kotlin.reflect.jvm.isAccessible /** * Helper class to invoke [KotlinSignatureProvider] private build methods via reflection. */ class KotlinSignatureReflectionHelper( private val kotlin: KotlinSignatureProvider, ) { /** * Invokes a private build method on [kotlin] using reflection. * @param methodName the name of the method to invoke * @param args the arguments to pass */ private fun PageContentBuilder.DocumentableContentBuilder.invokeKotlinSignatureBuilderMethod( methodName: String, vararg args: Any, ) { kotlin::class .members .find { it.name == methodName } ?.apply { isAccessible = true } ?.call(kotlin, this, *args) } /** * Adds a projection/type signature to [this] builder. */ fun PageContentBuilder.DocumentableContentBuilder.projectionSignature(projection: Projection) { invokeKotlinSignatureBuilderMethod( "signatureForProjection", projection, false, ) } /** * Adds a parameter default value assignment to [this] builder. */ fun PageContentBuilder.DocumentableContentBuilder.defaultValue(parameter: DParameter) { parameter.sourceSets.forEach { sourceSet -> invokeKotlinSignatureBuilderMethod("defaultValueAssign", parameter, sourceSet) } } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/signature/LineBreakingStrategy.kt ================================================ package com.quarkdown.quarkdoc.dokka.signature import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import kotlin.math.max private const val MIN_PARAMETERS_TO_SPLIT_LINES = 3 /** * Strategy that defines line breaks in the function signature. * @see QuarkdownSignatureProvider */ interface LineBreakingStrategy { /** * Defines line breaking before each function parameter. * @param parameter the parameter to create content for * @param index the index of the parameter */ fun PageContentBuilder.DocumentableContentBuilder.beforeParameter( parameter: DParameter, index: Int, ) /** * Defines line breaking before the return type. */ fun PageContentBuilder.DocumentableContentBuilder.beforeReturn() companion object { /** * Creates a [LineBreakingStrategy] based on the function's parameter count. * @param function the function to analyze * @return a coherent [LineBreakingStrategy] instance */ fun fromFunction(function: DFunction): LineBreakingStrategy { val splitLines = function.parameters.size >= MIN_PARAMETERS_TO_SPLIT_LINES return when { splitLines -> SplitLineBreakingStrategy(function) else -> NoLineBreakingStrategy() } } } } /** * No line breaking. */ private class NoLineBreakingStrategy : LineBreakingStrategy { override fun PageContentBuilder.DocumentableContentBuilder.beforeParameter( parameter: DParameter, index: Int, ) { } override fun PageContentBuilder.DocumentableContentBuilder.beforeReturn() { punctuation(" ") } } /** * Adds a line break and indents each parameter. */ private class SplitLineBreakingStrategy( function: DFunction, ) : LineBreakingStrategy { private val firstParameterNameLength = function.parameters .firstOrNull() ?.name ?.length ?: 0 private val maxParameterNameLength = function.parameters.maxOfOrNull { it.name?.length ?: 0 } ?: 0 private val minPad = BEGIN.length + function.name.length private fun PageContentBuilder.DocumentableContentBuilder.pad(size: Int) { if (size <= 0) return punctuation(" ".repeat(size)) } /** * Result: * ``` * .func abcd:{Int} * ef:{String} * ghijkl:{Int} * ``` */ override fun PageContentBuilder.DocumentableContentBuilder.beforeParameter( parameter: DParameter, index: Int, ) { val parameterNameLength = parameter.name?.length ?: 0 val supplementPad = max(minPad, maxParameterNameLength - firstParameterNameLength) if (index == 0) { // The first parameter can have a small padding in case another parameter has a long name. pad(supplementPad - minPad) return } breakLine() pad(supplementPad + firstParameterNameLength - parameterNameLength) } override fun PageContentBuilder.DocumentableContentBuilder.beforeReturn() { breakLine() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/signature/QuarkdownSignatureProvider.kt ================================================ package com.quarkdown.quarkdoc.dokka.signature import com.quarkdown.core.parser.walker.funcall.FunctionCallGrammar import com.quarkdown.quarkdoc.dokka.transformers.module.QuarkdownModulesStorage import com.quarkdown.quarkdoc.dokka.util.documentableContentBuilder import org.jetbrains.dokka.base.signatures.KotlinSignatureProvider import org.jetbrains.dokka.base.signatures.SignatureProvider import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.pages.ContentNode import org.jetbrains.dokka.pages.TokenStyle import org.jetbrains.dokka.plugability.DokkaContext internal const val BEGIN = FunctionCallGrammar.BEGIN.toString() internal const val CHAINING_DELIMITER = FunctionCallGrammar.CHAIN_SEPARATOR private const val INLINE_PARAMETER_START = FunctionCallGrammar.ARGUMENT_BEGIN.toString() private const val INLINE_PARAMETER_END = FunctionCallGrammar.ARGUMENT_END.toString() private const val INLINE_PARAMETER_DELIMITER = " " private const val PARAMETER_NAME_DELIMITER = FunctionCallGrammar.NAMED_ARGUMENT_DELIMITER private const val RETURN_TYPE_DELIMITER = "-> " /** * Signature provider for Quarkdown functions. * @param requireModule whether to require the function to be in a Quarkdown module * @param defaultValues whether to include default values for arguments * @param withChaining whether to format the signature for chained calls */ class QuarkdownSignatureProvider( private val context: DokkaContext, private val requireModule: Boolean = true, private val defaultValues: Boolean = true, private val withChaining: Boolean = false, ) : SignatureProvider { private val kotlin = KotlinSignatureProvider(context) private val helper = KotlinSignatureReflectionHelper(kotlin) override fun signature(documentable: Documentable): List<ContentNode> { if (requireModule && !QuarkdownModulesStorage.isModule(documentable)) { return kotlin.signature(documentable) } val builder = context.documentableContentBuilder( documentable, setOf(documentable.dri), ) return when (documentable) { is DFunction -> { builder .buildGroup { codeBlock { signature(documentable) } }.children } else -> { kotlin.signature(documentable) } } } private fun PageContentBuilder.DocumentableContentBuilder.signature(function: DFunction) = with(helper) { // Without chaining: .func param1:{Type1} param2:{Type2} // With chaining: Type1::func param2:{Type2} if (withChaining && function.parameters.isNotEmpty()) { projectionSignature(function.parameters.first().type) punctuation(CHAINING_DELIMITER) } else { punctuation(BEGIN) } text(function.name, styles = setOf(TokenStyle.Function)) val lineBreakingStrategy = LineBreakingStrategy.fromFunction(function) val parameters = when { withChaining -> function.parameters.drop(1) else -> function.parameters } parameters.forEachIndexed { index, parameter -> lineBreakingStrategy.run { beforeParameter(parameter, index) } signature(parameter) } lineBreakingStrategy.run { beforeReturn() } operator(RETURN_TYPE_DELIMITER) projectionSignature(function.type) } private fun PageContentBuilder.DocumentableContentBuilder.signature(parameter: DParameter) = with(helper) { punctuation(INLINE_PARAMETER_DELIMITER) constant(parameter.name ?: "<unnamed>") operator(PARAMETER_NAME_DELIMITER) punctuation(INLINE_PARAMETER_START) projectionSignature(parameter.type) if (defaultValues) defaultValue(parameter) punctuation(INLINE_PARAMETER_END) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/QuarkdocDocumentableReplacerTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers import org.jetbrains.dokka.base.transformers.documentables.DocumentableReplacerTransformer import org.jetbrains.dokka.model.Bound import org.jetbrains.dokka.model.DClasslike import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DModule import org.jetbrains.dokka.model.DPackage import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.DProperty import org.jetbrains.dokka.model.GenericTypeConstructor import org.jetbrains.dokka.model.Nullable import org.jetbrains.dokka.plugability.DokkaContext /** * Utility that extends [DocumentableReplacerTransformer] to allow for easy transformation of functions and parameters * while relying on the default behavior of the base class. */ open class QuarkdocDocumentableReplacerTransformer( context: DokkaContext, ) : DocumentableReplacerTransformer(context) { protected fun <T> T.changed(changed: Boolean = true) = AnyWithChanges(this, changed = changed) protected fun <T> T.unchanged() = changed(changed = false) private fun <T> AnyWithChanges<T>.merge(other: AnyWithChanges<T>): AnyWithChanges<T> = AnyWithChanges( target = other.target, changed = this.changed || other.changed, ) protected fun <T> AnyWithChanges<T>.merge(other: (T) -> AnyWithChanges<T>): AnyWithChanges<T> = this.merge(other(this.target!!)) protected open fun transformModule(module: DModule) = module.unchanged() override fun processModule(module: DModule) = super.processModule(module).merge(::transformModule) protected open fun transformPackage(pkg: DPackage) = pkg.unchanged() override fun processPackage(dPackage: DPackage) = super.processPackage(dPackage).merge(::transformPackage) protected open fun transformClassLike(classlike: DClasslike) = classlike.unchanged() override fun processClassLike(classlike: DClasslike) = super.processClassLike(classlike).merge(::transformClassLike) protected open fun transformProperty(property: DProperty) = property.unchanged() override fun processProperty(dProperty: DProperty) = super.processProperty(dProperty).merge(::transformProperty) protected open fun transformFunction(function: DFunction) = function.unchanged() override fun processFunction(dFunction: DFunction) = super.processFunction(dFunction).merge(::transformFunction) protected open fun transformParameter(parameter: DParameter) = parameter.unchanged() override fun processParameter(dParameter: DParameter) = super.processParameter(dParameter).merge(::transformParameter) protected open fun transformType(type: GenericTypeConstructor) = type.unchanged() override fun processGenericTypeConstructor(genericTypeConstructor: GenericTypeConstructor) = super.processGenericTypeConstructor(genericTypeConstructor).merge(::transformType) // Dokka's DocumentableReplacerTransformer implementation does not propagate into Nullable types for some reason. private fun transformNullableType(nullable: Nullable) = super .processBound(nullable.inner) .takeIf { it.changed } ?.let { nullable.copy(inner = it.target!!).changed() } ?: nullable.unchanged() override fun processBound(bound: Bound): AnyWithChanges<Bound> = super .processBound(bound) .merge { when (it) { is Nullable -> transformNullableType(it) else -> it.unchanged() } } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/QuarkdocParameterDocumentationTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers import com.quarkdown.core.util.filterNotNullEntries import com.quarkdown.quarkdoc.dokka.kdoc.mapDocumentation import com.quarkdown.quarkdoc.dokka.util.tryCopy import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.doc.DocTag import org.jetbrains.dokka.model.doc.Param import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that extends the documentation of function parameters, * according to additional information extracted from the parameters, of type [T]. * @param T the type of the additional information extracted from the parameters * @see com.quarkdown.quarkdoc.dokka.transformers.optional.AdditionalParameterPropertiesTransformer * @see com.quarkdown.quarkdoc.dokka.transformers.enumeration.EnumParameterEntryListerTransformer */ abstract class QuarkdocParameterDocumentationTransformer<T>( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { /** * @return the value of type [T] extracted from the given [parameter], if any */ protected abstract fun extractValue(parameter: DParameter): T? /** * @return the parameters, among [parameters], that have a non-null extracted value, associated with their enum declaration. */ private fun associateParameters(parameters: List<DParameter>): Map<String, T> = parameters .asSequence() .map { it.name to extractValue(it) } .filterNotNullEntries() .toMap() /** * @return the documentation content to add to the parameter documentation */ protected abstract fun createNewDocumentation(value: T): List<DocTag> /** * Merges the old and new documentation content. * For example, `old + new` to append the new content to the old one. * @param old the existing documentation content * @param new the new documentation content to add, form [createNewDocumentation] * @return the merged documentation content */ protected abstract fun mergeDocumentationContent( old: List<DocTag>, new: List<DocTag>, ): List<DocTag> override fun transformFunction(function: DFunction): AnyWithChanges<DFunction> { val values: Map<String, T> = associateParameters(function.parameters) .takeIf { it.isNotEmpty() } ?: return function.unchanged() // Updates the documentation of the parameters to include the properties. val documentation = mapDocumentation(function.documentation) { register(Param::class) { param -> val value = values[param.name] ?: return@register param val root = param.root val documentation = mergeDocumentationContent(root.children, createNewDocumentation(value)) param.copy( root = root.tryCopy(newChildren = documentation), ) } } return function.copy(documentation = documentation).changed() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/EnumParameterEntryListerTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration import com.quarkdown.core.function.toQuarkdownNamingFormat import com.quarkdown.quarkdoc.dokka.kdoc.buildDocTags import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocParameterDocumentationTransformer import com.quarkdown.quarkdoc.dokka.transformers.enumeration.adapters.QuarkdocEnumAdapters import com.quarkdown.quarkdoc.dokka.util.scrapingAnchor import com.quarkdown.quarkdoc.reader.anchors.Anchors import org.jetbrains.dokka.base.signatures.KotlinSignatureUtils.driOrNull import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.doc.DocTag import org.jetbrains.dokka.plugability.DokkaContext /** * A transformer that, given a parameter that expects an enum value, * lists the enum entries in its documentation. */ class EnumParameterEntryListerTransformer( context: DokkaContext, ) : QuarkdocParameterDocumentationTransformer<QuarkdocEnum>(context) { /** * @return the enum type of the parameter, if it is an enum */ override fun extractValue(parameter: DParameter): QuarkdocEnum? = parameter.type.driOrNull?.let(QuarkdocEnumAdapters::fromDRI) override fun createNewDocumentation(value: QuarkdocEnum): List<DocTag> = buildDocTags { h4 { text("Values") } scrapingAnchor(Anchors.VALUES) unorderedList { value.entries.forEach { entry -> listItem { link(dri = entry.dri) { codeInline(entry.name.toQuarkdownNamingFormat()) } } } } } override fun mergeDocumentationContent( old: List<DocTag>, new: List<DocTag>, ) = old + new } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/EnumStorage.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.DEnum /** * Storage of enum declarations. * @see EnumStorer * @see EnumParameterEntryListerTransformer */ object EnumStorage { private val enums = mutableSetOf<DEnum>() /** * Registers an enum declaration. */ operator fun plusAssign(enum: DEnum) { enums += enum } /** * @return the enum declaration associated with the given [dri], if any */ fun fromDRI(dri: DRI) = enums.find { it.dri == dri } /** * Removes all enum declarations from this storage. */ fun clear() { enums.clear() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/EnumStorer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.transformers.module.QuarkdownModulesStorage import org.jetbrains.dokka.model.DClasslike import org.jetbrains.dokka.model.DEnum import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that, instead of performing transformations, stores enum declarations within the module this plugin is applied on. * @see QuarkdownModulesStorage * @see EnumParameterEntryListerTransformer */ class EnumStorer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { override fun transformClassLike(classlike: DClasslike): AnyWithChanges<DClasslike> { if (classlike is DEnum) { EnumStorage += classlike } return classlike.unchanged() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/QuarkdocEnum.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration import org.jetbrains.dokka.links.DRI /** * An enum that can be represented and listed in documentation. */ interface QuarkdocEnum { /** * The ordered entries of the enum. */ val entries: List<QuarkdocEnumEntry> } /** * An entry of an enum that can be represented in documentation. */ interface QuarkdocEnumEntry { /** * The name of the entry. */ val name: String /** * The [DRI] to the entry definition. */ val dri: DRI } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/adapters/DokkaEnumAdapter.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration.adapters import com.quarkdown.quarkdoc.dokka.transformers.enumeration.QuarkdocEnum import com.quarkdown.quarkdoc.dokka.transformers.enumeration.QuarkdocEnumEntry import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.DEnum import org.jetbrains.dokka.model.DEnumEntry /** * An adapter for a [QuarkdocEnum] from a Dokka-loaded enum. * @param enum the enum model */ internal class DokkaEnumAdapter( private val enum: DEnum, ) : QuarkdocEnum { override val entries: List<QuarkdocEnumEntry> get() = enum.entries.map(::DokkaEnumEntryAdapter) } /** * An adapter for a [QuarkdocEnumEntry] from a Dokka-loaded enum entry. * @param entry the enum entry model */ internal class DokkaEnumEntryAdapter( private val entry: DEnumEntry, ) : QuarkdocEnumEntry { override val name: String get() = entry.name override val dri: DRI get() = entry.dri } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/adapters/QuarkdocEnumAdapters.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration.adapters import com.quarkdown.quarkdoc.dokka.transformers.enumeration.EnumStorage import com.quarkdown.quarkdoc.dokka.transformers.enumeration.QuarkdocEnum import com.quarkdown.quarkdoc.dokka.util.fullyQualifiedReflectionName import org.jetbrains.dokka.links.DRI /** * Utilities to adapt a [QuarkdocEnum] from different sources. */ object QuarkdocEnumAdapters { /** * Looks up a [QuarkdocEnum] from the given [DRI]. * - If the enum is declared in the same module, it will be found in the [EnumStorage] as a [DokkaEnumAdapter]. * - If the enum is declared in a different module that is present in this classpath (e.g. `core`), * it will be loaded via reflection as a [ReflectionEnumAdapter]. * @param dri the [DRI] that points to the enum declaration. * @return a [QuarkdocEnum] from the given [DRI], or `null` if it cannot be found. */ fun fromDRI(dri: DRI): QuarkdocEnum? = EnumStorage.fromDRI(dri)?.let(::DokkaEnumAdapter) ?: try { Class .forName(dri.fullyQualifiedReflectionName) .takeIf { it.isEnum } ?.let { ReflectionEnumAdapter(it, dri) } } catch (_: Exception) { null } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/enumeration/adapters/ReflectionEnumAdapter.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.enumeration.adapters import com.quarkdown.quarkdoc.dokka.transformers.enumeration.QuarkdocEnum import com.quarkdown.quarkdoc.dokka.transformers.enumeration.QuarkdocEnumEntry import org.jetbrains.dokka.links.DRI /** * An adapter for a [QuarkdocEnum] that is loaded via reflection. * @param cls the enum class * @param dri the [DRI] that points to the enum declaration */ internal class ReflectionEnumAdapter( private val cls: Class<*>, private val dri: DRI, ) : QuarkdocEnum { override val entries: List<QuarkdocEnumEntry> get() = cls.enumConstants .filterIsInstance<Enum<*>>() .map { ReflectionEnumEntryAdapter(it, dri) } } /** * An adapter for a [QuarkdocEnumEntry] that is loaded via reflection from a [ReflectionEnumAdapter]. * @param entry the enum entry * @param parentDri the [DRI] that points to the enum declaration */ internal class ReflectionEnumEntryAdapter( private val entry: Enum<*>, private val parentDri: DRI, ) : QuarkdocEnumEntry { override val name: String get() = entry.name override val dri: DRI get() = parentDri.copy(classNames = parentDri.classNames + '.' + entry.name) } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/misc/DocumentTypeConstraintsTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.misc import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.util.extractAnnotation import com.quarkdown.quarkdoc.dokka.util.parameterToEnumArray import com.quarkdown.quarkdoc.dokka.util.withAddedExtra import org.jetbrains.dokka.model.Annotations import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.properties.ExtraProperty import org.jetbrains.dokka.plugability.DokkaContext import kotlin.reflect.KProperty /** * Extra property that stores the document types a function supports, if specified. * @param targets the list of document types the function supports */ data class DocumentTargetProperty( val targets: List<DocumentType>, ) : ExtraProperty<DFunction> { companion object : ExtraProperty.Key<DFunction, DocumentTargetProperty> override val key = DocumentTargetProperty } /** * Given a function annotated with `@OnlyForDocumentType` or `@NotForDocumentType` which defines constraints * about the document type the function supports, these transformer add a [DocumentTargetProperty] extra property. * @see com.quarkdown.quarkdoc.dokka.page.DocumentTypeConstraintsPageTransformer * @see OnlyForDocumentType * @see NotForDocumentType */ object DocumentTypeConstraintsTransformer { /** * Base class for transformers that specify document type constraints to [DFunction]s, * retrieved from annotations in the function declaration. * If type constraints are present, the supported document types are stored * in the function's extra properties as [DocumentTargetProperty]. * @param annotationTypesParameterProperty the property of the annotation that contains the document types */ abstract class AbstractTransformer( private val annotationTypesParameterProperty: KProperty<Array<out DocumentType>>, context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { /** * @return the annotation extracted from the function declaration * which specifies the document type constraints, if any. */ abstract fun extractAnnotation(function: DFunction): Annotations.Annotation? /** * Given the document types extracted from the annotation, transforms them. */ abstract fun transformTypes(types: List<DocumentType>): List<DocumentType> override fun transformFunction(function: DFunction): AnyWithChanges<DFunction> { val types: List<DocumentType> = extractAnnotation(function) ?.parameterToEnumArray(annotationTypesParameterProperty.name, DocumentType::valueOf) ?.let(::transformTypes) ?: return function.unchanged() return function .withAddedExtra(DocumentTargetProperty(types)) .changed() } } /** * Given a function annotated with `@OnlyForDocumentType(X)`, * this transformer sets the function's constraint to only the document type `X`. */ class Positive( context: DokkaContext, ) : AbstractTransformer(annotationTypesParameterProperty = OnlyForDocumentType::types, context) { override fun extractAnnotation(function: DFunction) = function.extractAnnotation<OnlyForDocumentType>() override fun transformTypes(types: List<DocumentType>) = types } /** * Given a function annotated with `@NotForDocumentType(X)`, * this transformer sets the function's constraint to all document types except `X`. */ class Negative( context: DokkaContext, ) : AbstractTransformer(annotationTypesParameterProperty = NotForDocumentType::types, context) { override fun extractAnnotation(function: DFunction) = function.extractAnnotation<NotForDocumentType>() override fun transformTypes(types: List<DocumentType>) = DocumentType.entries - types } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/module/ModuleAsPackageTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.module import com.quarkdown.core.util.filterNotNullEntries import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.util.difference import com.quarkdown.quarkdoc.dokka.util.sourcePaths import org.jetbrains.dokka.DokkaConfiguration import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DModule import org.jetbrains.dokka.model.DPackage import org.jetbrains.dokka.plugability.DokkaContext /** * A synthetic module is a Quarkdown [com.quarkdown.core.function.library.loader.Module] that is defined in a source file. * The synthetic module contains the functions that are defined in the source file. * @param name the name of the synthetic module. * @param dri the DRI to the [com.quarkdown.core.function.library.loader.Module] property definition. * @param functions the functions that are defined in the source file of the module * @param sourceSets the source sets that are associated with the functions. */ private data class SyntheticModule( val name: String, val dri: DRI, val functions: List<DFunction>, val sourceSets: Set<DokkaConfiguration.DokkaSourceSet>, ) /** * Given a list of functions, extracts the Quarkdown modules that they belong to, according to [QuarkdownModulesStorage]. * @param functions functions to extract the synthetic modules from. * @return the list of synthetic modules. */ private fun extractSyntheticModules(functions: List<DFunction>): List<SyntheticModule> = functions .groupBy { function -> val sourcePath = function.sourcePaths.singleOrNull() ?: return@groupBy null QuarkdownModulesStorage[sourcePath] }.asSequence() .map { it.toPair() } .filterNotNullEntries() .map { (module, functions) -> SyntheticModule(module.name, module.dri, functions, functions.flatMap { it.sourceSets }.toSet()) }.toList() /** * Creates a synthetic subpackage of the given [parentPackage] for each module of [syntheticModules]. * @param parentPackage the package to create the synthetic subpackages for * @param syntheticModules the list of synthetic modules to create the subpackages for * @return the new synthetic packages */ private fun createSyntheticPackages( parentPackage: DPackage, syntheticModules: List<SyntheticModule>, ): List<DPackage> = syntheticModules.map { module -> parentPackage.copy( dri = DRI(packageName = parentPackage.packageName + ".module." + module.name), functions = module.functions, properties = emptyList(), classlikes = emptyList(), typealiases = emptyList(), ) } /** * Transformer that transforms a Quarkdown [com.quarkdown.core.function.library.loader.Module] * into a Dokka package containing the module's functions. */ class ModuleAsPackageTransformer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { override fun transformModule(module: DModule): AnyWithChanges<DModule> { val newPackages: List<DPackage> = module.packages .associateWith { extractSyntheticModules(it.functions) } .flatMap { (pkg, modules) -> createSyntheticPackages(pkg, modules) } return module .copy( packages = newPackages + module.packages.difference(newPackages), ).changed() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/module/ModulesStorer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.module import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.util.isOfType import com.quarkdown.quarkdoc.dokka.util.sourcePaths import org.jetbrains.dokka.model.DProperty import org.jetbrains.dokka.model.GenericTypeConstructor import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that, instead of performing transformations, * stores the [com.quarkdown.core.function.library.loader.Module] declarations associated with their source files. * @see QuarkdownModulesStorage */ class ModulesStorer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { private fun isModuleDefinition(property: DProperty): Boolean { val type = property.type as? GenericTypeConstructor ?: return false return type.dri.isOfType<QuarkdownModule>() } override fun transformProperty(property: DProperty): AnyWithChanges<DProperty> { if (!isModuleDefinition(property)) return property.unchanged() val dri = property.dri property.sourcePaths.singleOrNull()?.let { sourceFile -> QuarkdownModulesStorage[sourceFile] = StoredModule(name = property.name, dri = dri) } return property.unchanged() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/module/QuarkdownModulesStorage.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.module import com.quarkdown.quarkdoc.dokka.util.sourcePaths import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.model.WithSources /** * A Quarkdown module. * @param name the module name * @param dri the module DRI */ data class StoredModule( val name: String, val dri: DRI, ) /** * Storage that assigns Quarkdown modules to their .kt source file. */ object QuarkdownModulesStorage { // Path to the source file of the module declaration associated with its module private val modules = mutableMapOf<String, StoredModule>() operator fun get(sourcePath: String): StoredModule? = modules[sourcePath] operator fun set( sourcePath: String, module: StoredModule, ) { modules[sourcePath] = module } fun isModule(documentable: Documentable) = documentable is WithSources && documentable.sourcePaths.any { it in modules } /** * The number of modules stored in this storage. */ val moduleCount: Int get() = modules.size fun clear() { modules.clear() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/name/DocumentableNameTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.name import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that renames functions and parameters annotated with `@Name` within the function signature. * @see DocumentationNameTransformer for documentation-side renaming * @see RenamingsStorage */ class DocumentableNameTransformer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { private fun <D : Documentable> rename( documentable: D, copy: (String) -> D, ): AnyWithChanges<D> { val renaming = RenamingsStorage[documentable.dri] ?: return documentable.unchanged() return copy(renaming.newName).changed() } override fun transformFunction(function: DFunction) = rename(function) { name -> function.copy(name = name) } override fun transformParameter(parameter: DParameter) = rename(parameter) { name -> parameter.copy(name = name) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/name/DocumentationNameTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.name import com.quarkdown.quarkdoc.dokka.kdoc.DocumentationReferencesTransformer import com.quarkdown.quarkdoc.dokka.kdoc.DokkaDocumentation import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.model.doc.DocumentationLink import org.jetbrains.dokka.model.doc.Param import org.jetbrains.dokka.model.doc.See import org.jetbrains.dokka.model.doc.Text import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that renames functions and parameters annotated with `@Name` within the KDoc documentation. * @see DocumentableNameTransformer for signature-side renaming * @see RenamingsStorage */ class DocumentationNameTransformer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { private fun <D : Documentable> applyDocumentation( documentable: D, parameters: List<DParameter>, copy: (DokkaDocumentation) -> D, ): AnyWithChanges<D> { val documentation = NameTransformerDocumentationReferencesTransformer().transformReferences( documentation = documentable.documentation, parameters = parameters, ) return copy(documentation).changed(documentation != documentable.documentation) } override fun transformFunction(function: DFunction): AnyWithChanges<DFunction> = applyDocumentation(function, function.parameters) { function.copy(documentation = it) } override fun transformParameter(parameter: DParameter): AnyWithChanges<DParameter> = applyDocumentation(parameter, listOf(parameter)) { parameter.copy(documentation = it) } } /** * Transformer that applies renamings to KDoc references in the documentation. */ private class NameTransformerDocumentationReferencesTransformer : DocumentationReferencesTransformer { override fun onLink(link: DocumentationLink): DocumentationLink { val renaming = RenamingsStorage[link.dri] ?: return link val textChild = link.children.singleOrNull() as? Text ?: return link val newParams = link.params.toMutableMap().apply { this["href"] = "[${renaming.newName}]" } return link.copy( children = listOf(textChild.copy(body = renaming.newName)), params = newParams, ) } override fun onParam( param: Param, actualParameter: DParameter, ): Param { val renaming = RenamingsStorage[actualParameter.dri] ?: return param return param.copy(name = renaming.newName) } override fun onSee(see: See): See { val renaming = RenamingsStorage[requireNotNull(see.address)] ?: return see return see.copy(name = renaming.newName) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/name/RenamingsStorage.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.name import org.jetbrains.dokka.links.DRI /** * A renaming of a function or parameter via the `@Name` annotation. * @property oldName the original name * @property newName the new name */ data class Renaming( val oldName: String, val newName: String, ) /** * Storage for the old-new function name pairs. * This is a mutable map that is populated by the [RenamingsStorer] transformer. */ object RenamingsStorage { private val renamings: MutableMap<DRI, Renaming> = mutableMapOf() /** * @return the new name for the function with the given DRI, or null if it is not found. */ operator fun get(dri: DRI): Renaming? = renamings[dri] /** * Updates the renaming for the given address. */ operator fun set( dri: DRI, renaming: Renaming, ) { renamings[dri] = renaming } /** * Clears the stored renamings. */ fun clear() { renamings.clear() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/name/RenamingsStorer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.name import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.util.extractAnnotation import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that, instead of performing transformations, * stores the old-new function name pairs into [RenamingsStorage]. * This should be executed before other transformers that rely on the renamings. */ class RenamingsStorer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { /** * @return the optional overridden name of the function or parameter, or `null` if not annotated with `@Name`. */ private fun getOverriddenName(documentable: Documentable): String? { val nameAnnotation = documentable.extractAnnotation<Name>() return nameAnnotation?.params?.get(Name::name.name)?.toString() } private fun storeIfRenamed(documentable: Documentable) { val name = getOverriddenName(documentable) ?: return RenamingsStorage[documentable.dri] = Renaming( oldName = requireNotNull(documentable.name), newName = name, ) } override fun transformFunction(function: DFunction): AnyWithChanges<DFunction> { storeIfRenamed(function) return super.transformFunction(function) } override fun transformParameter(parameter: DParameter): AnyWithChanges<DParameter> { storeIfRenamed(parameter) return super.transformParameter(parameter) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/optional/AdditionalParameterPropertiesTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.optional import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.factory.ValueFactory.enum import com.quarkdown.quarkdoc.dokka.kdoc.buildDocTags import com.quarkdown.quarkdoc.dokka.page.WIKI_ROOT import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocParameterDocumentationTransformer import com.quarkdown.quarkdoc.dokka.util.hasAnnotation import com.quarkdown.quarkdoc.dokka.util.scrapingAnchor import com.quarkdown.quarkdoc.reader.anchors.Anchors import org.jetbrains.dokka.model.DParameter import org.jetbrains.dokka.model.DefaultValue import org.jetbrains.dokka.model.doc.Dl import org.jetbrains.dokka.model.doc.DocTag import org.jetbrains.dokka.model.doc.Ul import org.jetbrains.dokka.plugability.DokkaContext private const val BODY_PARAMETER_WIKI_URL = WIKI_ROOT + "syntax-of-a-function-call#block-vs-inline-function-calls" private const val NAMED_PARAMETER_WIKI_URL = WIKI_ROOT + "syntax-of-a-function-call" /** * Transformer that appends documentation to parameters * indicating additional properties, such as: * - whether the parameter is optional */ class AdditionalParameterPropertiesTransformer( context: DokkaContext, ) : QuarkdocParameterDocumentationTransformer<AdditionalParameterPropertiesTransformer.ParameterProperties>(context) { /** * Additional properties of a parameter that can be documented. * @property isOptional whether the parameter is optional * @property isLikelyNamed whether the parameter is likely passed as a named argument * @property isLikelyBody whether the parameter is likely passed as a body argument */ data class ParameterProperties( val isOptional: Boolean, val isLikelyNamed: Boolean, val isLikelyBody: Boolean, ) override fun extractValue(parameter: DParameter) = ParameterProperties( isOptional = parameter.extra[DefaultValue] != null, isLikelyNamed = parameter.hasAnnotation<LikelyNamed>() || parameter.hasAnnotation<Name>(), isLikelyBody = parameter.hasAnnotation<LikelyBody>(), ) /** * @return the documentation content to add to the parameter documentation, * which lists the enum entries of the given [enum]. */ override fun createNewDocumentation(value: ParameterProperties): List<DocTag> = buildDocTags { if (value.isLikelyBody) { listItem { scrapingAnchor(Anchors.LIKELY_BODY) text("Likely a ") link(address = BODY_PARAMETER_WIKI_URL, "body argument") } } if (value.isOptional) { listItem { scrapingAnchor(Anchors.OPTIONAL) text("Optional") } } if (value.isLikelyNamed) { listItem { scrapingAnchor(Anchors.LIKELY_NAMED) text("Likely ") link(address = NAMED_PARAMETER_WIKI_URL, "named") } } }.takeUnless { it.isEmpty() } ?.let { listOf( Dl(listOf(Ul(it))), ) } ?: emptyList() override fun mergeDocumentationContent( old: List<DocTag>, new: List<DocTag>, ) = new + old } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/suppress/SuppressInjectedTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.suppress import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import com.quarkdown.quarkdoc.dokka.util.hasAnnotation import org.jetbrains.dokka.model.DFunction import org.jetbrains.dokka.plugability.DokkaContext /** * Transformer that suppresses parameters annotated with `@Injected` in the generated documentation. */ class SuppressInjectedTransformer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { override fun transformFunction(function: DFunction) = function .copy(parameters = function.parameters.filterNot { it.hasAnnotation<Injected>() }) .let { it.changed(changed = it.parameters.size != function.parameters.size) } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/transformers/type/ValueTypeTransformer.kt ================================================ package com.quarkdown.quarkdoc.dokka.transformers.type import com.quarkdown.core.function.value.InputValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.quarkdoc.dokka.transformers.QuarkdocDocumentableReplacerTransformer import org.jetbrains.dokka.base.signatures.KotlinSignatureUtils.driOrNull import org.jetbrains.dokka.model.GenericTypeConstructor import org.jetbrains.dokka.model.Projection import org.jetbrains.dokka.model.Variance import org.jetbrains.dokka.plugability.DokkaContext private const val VALUE_SUFFIX = "Value" /** * Transformer that renames [Value] and subclasses in the signature to a more human-readable form. * For example: * - `NumberValue` -> `Number` * - `IterableValue` -> `Iterable` * - `OutputValue` -> `Any` */ class ValueTypeTransformer( context: DokkaContext, ) : QuarkdocDocumentableReplacerTransformer(context) { override fun transformType(type: GenericTypeConstructor): AnyWithChanges<GenericTypeConstructor> { val dri = type.dri val className = dri.classNames ?: return type.unchanged() val (newClassName: String, newProjections: List<Projection>?) = when { // Value<*>, InputValue<*>, OutputValue<*> -> Any className == Value::class.simpleName || className == OutputValue::class.simpleName || className == InputValue::class.simpleName -> { "Any" to emptyList<Projection>() } // ObjectValue<Xyz> -> Xyz className == ObjectValue::class.simpleName -> { val projection = type.projections.firstOrNull() val projectionName = (projection as? Variance<*>) ?.inner ?.driOrNull ?.classNames projectionName?.let { it to emptyList() } ?: return type.unchanged() } // XyzValue -> Xyz className.endsWith(VALUE_SUFFIX) -> { className.removeSuffix(VALUE_SUFFIX) to null } else -> return type.unchanged() } return type .copy(dri = dri.copy(classNames = newClassName)) .let { if (newProjections != null) it.copy(projections = newProjections) else it } .changed() } } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Annotation.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.Annotations import org.jetbrains.dokka.model.ArrayValue import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.model.EnumValue import org.jetbrains.dokka.model.properties.WithExtraProperties /** * @returns the first annotation of type [T] found in the documentable */ inline fun <reified T> Documentable.extractAnnotation(): Annotations.Annotation? { val annotations = (this as? WithExtraProperties<*>) ?.extra ?.allOfType<Annotations>() ?.flatMap { it.directAnnotations.values.flatten() } ?: emptyList() return annotations.find { it.dri.isOfType<T>() } } /** * @returns whether the documentable has an annotation of type [T] */ inline fun <reified T> Documentable.hasAnnotation(): Boolean = extractAnnotation<T>() != null /** * Converts the parameter of an annotation to a list of enums of type [E] using the provided [valueOf] function. * @param paramName the name of the parameter in the annotation * @param valueOf a function that converts an enum name to an enum of type [E] * @return a list of enums of type [E] corresponding to the parameter value * @see EnumValue.toEnum */ fun <E : Enum<*>> Annotations.Annotation.parameterToEnumArray( paramName: String, valueOf: (String) -> E, ): List<E> = (this.params[paramName] as? ArrayValue) ?.value ?.asSequence() ?.filterIsInstance<EnumValue>() ?.map { it.toEnum(valueOf) } ?.toList() ?: emptyList() ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/ContentBuilder.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.base.DokkaBase import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.model.Documentable import org.jetbrains.dokka.model.properties.PropertyContainer import org.jetbrains.dokka.plugability.DokkaContext import org.jetbrains.dokka.plugability.plugin import org.jetbrains.dokka.plugability.querySingle private fun DokkaContext.contentBuilder() = PageContentBuilder( plugin<DokkaBase>().querySingle { commentsToContentConverter }, plugin<DokkaBase>().querySingle { signatureProvider }, logger, ) /** * Creates a [PageContentBuilder.DocumentableContentBuilder] for the given [Documentable]. */ fun DokkaContext.documentableContentBuilder( documentable: Documentable, dri: Set<DRI>, ) = contentBuilder().DocumentableContentBuilder( dri, mainSourcesetData = documentable.sourceSets, emptySet(), PropertyContainer.empty(), ) ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/DocTagCopy.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.WithChildren import org.jetbrains.dokka.model.doc.CustomDocTag import org.jetbrains.dokka.model.doc.DocTag import kotlin.reflect.full.instanceParameter /** * Attempts to copy the current instance of [this] with the new children provided. * @param newChildren the new children to set in the copied instance * @return a new instance of the same type as [this] with the new children set, or [this] if the copy operation fails */ @Suppress("UNCHECKED_CAST") fun <T : WithChildren<*>> T.tryCopy(newChildren: List<DocTag>): T { if (newChildren == this.children) return this // This implementation via reflection is a terrible workaround, yet the most convenient one. val copyMethod = this::class.members.find { it.name == "copy" } ?: return this val parameters = copyMethod.parameters val args = parameters .associateWith { parameter -> when (parameter.name) { "children" -> newChildren "child", "root" -> newChildren.singleOrNull() ?: CustomDocTag(newChildren, name = "") else -> null } }.filterValues { it != null } .plus(copyMethod.instanceParameter!! to this) return copyMethod.callBy(args) as T } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Dri.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.links.DRI import org.jetbrains.dokka.links.sureClassNames fun DRI.isOfType( packageName: String, className: String, ): Boolean = this.packageName == packageName && this.classNames == className /** * @return whether the [DRI] points to the type [T] */ inline fun <reified T> DRI.isOfType(): Boolean { val packageName = T::class.java.`package`.name val className = T::class.simpleName return isOfType(packageName, className ?: "") } /** * @return the fully qualified name of the class represented, ready to be passed to [Class.forName] * @throws IllegalStateException if the DRI does not represent a class */ val DRI.fullyQualifiedReflectionName: String get() = "$packageName.${sureClassNames.replace('.', '$')}" ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Enum.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.EnumValue /** * Converts an [EnumValue] to an enum of type [E] using the provided [valueOf] function. * @param valueOf a function that converts an enum name to an enum of type [E] * @return the enum of type [E] corresponding to the [EnumValue] */ fun <E : Enum<*>> EnumValue.toEnum(valueOf: (String) -> E): E = valueOf(enumName.substringAfterLast('.')) ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Extra.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.properties.ExtraProperty import org.jetbrains.dokka.model.properties.WithExtraProperties /** * Appends the given [extra] property to the given object. * @param extra the extra properties to add * @return a new instance of [this] object with the added extra properties */ fun <T : Any, W : WithExtraProperties<T>> W.withAddedExtra(vararg extra: ExtraProperty<T>) = this.withNewExtras( this.extra.addAll(listOf(*extra)), ) ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/KDoc.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.WithChildren import org.jetbrains.dokka.model.withDescendants /** * Finds the first child of type [T] in the documentation tree, starting from [this] root. * @param predicate optional predicate to filter the children of type [T] * @return the first child of type [T] that matches the predicate, if any */ inline fun <reified T> WithChildren<*>.findDeep(crossinline predicate: (T) -> Boolean = { true }): T? = withDescendants().firstOrNull { it is T && predicate(it) } as? T ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Package.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.DPackage /** * @param others the list of packages to subtract from [this] collection of packages. * @return a new list of packages with the functions, properties, and classlikes from [this] collection of packages * excluding those that are also in [others]. */ fun Iterable<DPackage>.difference(others: Iterable<DPackage>): List<DPackage> = map { pkg -> pkg.copy( functions = pkg.functions - others.flatMap { it.functions }, properties = pkg.properties - others.flatMap { it.properties }, classlikes = pkg.classlikes - others.flatMap { it.classlikes }, typealiases = pkg.typealiases - others.flatMap { it.typealiases }, ) } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Scraping.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import com.quarkdown.quarkdoc.dokka.kdoc.DocTagBuilder import com.quarkdown.quarkdoc.reader.anchors.AnchorsHtml import org.jetbrains.dokka.base.translators.documentables.PageContentBuilder // Utilities for facilitating scraping of documentation content via quarkdoc-reader. /** * Creates an anchor element at documentation level for scraping purposes, which marks a specific section of documentation. * @param anchor the name of the anchor to create * @receiver the [DocTagBuilder] to add the anchor to */ fun DocTagBuilder.scrapingAnchor(anchor: String) { assert(AnchorsHtml.ANCHOR_TAG == "a") assert(AnchorsHtml.ANCHOR_ATTRIBUTE == "href") link(address = AnchorsHtml.toAnchorAttribute(anchor)) {} } /** * Creates an anchor element at rendering level for scraping purposes, which marks a specific section of documentation. * @param anchor the name of the anchor to create * @receiver the [PageContentBuilder.DocumentableContentBuilder] to add the anchor to */ fun PageContentBuilder.DocumentableContentBuilder.scrapingAnchor(anchor: String) { assert(AnchorsHtml.ANCHOR_TAG == "a") assert(AnchorsHtml.ANCHOR_ATTRIBUTE == "href") link(text = "", address = AnchorsHtml.toAnchorAttribute(anchor)) } ================================================ FILE: quarkdown-quarkdoc/src/main/kotlin/com/quarkdown/quarkdoc/dokka/util/Source.kt ================================================ package com.quarkdown.quarkdoc.dokka.util import org.jetbrains.dokka.model.WithSources /** * The paths to the source files of a documentable object. */ val WithSources.sourcePaths: List<String> get() = sources.values.map { it.path } ================================================ FILE: quarkdown-quarkdoc/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin ================================================ com.quarkdown.quarkdoc.dokka.QuarkdocDokkaPlugin ================================================ FILE: quarkdown-quarkdoc/src/main/resources/styles/stylesheet.css ================================================ /* package.module.Abc => Abc */ #sideMenu a[href*='module.']:has(wbr) > span:not(:last-child) { display: none; } #sideMenu .sideMenu { display: flex; flex-direction: column; } #sideMenu .sideMenu > .toc--part:not([data-active]) > .toc--row > .toc--button:not(:hover) { background-color: transparent; } #sideMenu .sideMenu > .toc--part { order: 10; } /* User libraries (e.g. stdlib) show on top. */ #sideMenu .sideMenu > .toc--part:has(a[href*='module.']) { order: 1; } /* Shows a label on top of user libraries. */ .toc--part[data-nesting-level="0"]:has(a[href*='module.']) > .toc--row > a:before { content: 'User Library'; display: block; text-transform: uppercase; opacity: .5; } #sideMenu .sideMenu > .toc--part:has(a[href*='module.']) > .toc--row { background-color: rgba(77, 187, 95, .1); } /* If a package contains Quarkdown modules, non-module packages are semi-hidden. */ .toc--part:has(a[href*='module.']) > .toc--part:has(a:not([href*='module.'])) > .toc--row { opacity: .5; } /* Extra parameter properties. */ .table-row dl > ul { opacity: .5; padding: 0; display: inline-flex; flex-direction: row; list-style-position: inside; gap: 0.7em; } .table-row dl > ul > li:first-child { list-style: none; } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/AdditionalParameterPropertiesTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.quarkdoc.reader.anchors.Anchors import com.quarkdown.quarkdoc.reader.anchors.AnchorsHtml import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertFalse import kotlin.test.assertTrue private const val OPTIONAL_TEXT = "Optional" private const val BODY_TEXT = "Likely a body argument" private const val NAMED_TEXT = "Likely named" /** * Tests for [com.quarkdown.quarkdoc.dokka.transformers.optional.AdditionalParameterPropertiesTransformer]. */ class AdditionalParameterPropertiesTransformerTest : QuarkdocDokkaTest( stringImports = listOf(LikelyBody::class.qualifiedName!!, LikelyNamed::class.qualifiedName!!), stringPaths = listOf(LikelyBody::class.java.packageName + ".QuarkdocAnnotations"), ) { private fun containsAnchor( html: String, anchor: String, ): Boolean = AnchorsHtml.toAnchorAttribute(anchor) in html @Test fun `no additional properties`() { test( """ /** * @param x Test */ fun func(x: Int) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertFalse(OPTIONAL_TEXT in parameters) assertFalse(BODY_TEXT in parameters) assertFalse(containsAnchor(it, Anchors.LIKELY_NAMED)) assertFalse(containsAnchor(it, Anchors.LIKELY_BODY)) } } @Test fun `only optional parameter`() { test( """ /** * @param x Test */ fun func(x: Int = 0) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, OPTIONAL_TEXT) assertTrue(containsAnchor(it, Anchors.OPTIONAL)) } } @Test fun `body parameter`() { test( """ /** * @param x Test */ fun func(@LikelyBody x: Int) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, BODY_TEXT) assertTrue(containsAnchor(it, Anchors.LIKELY_BODY)) assertFalse(OPTIONAL_TEXT in parameters) } } @Test fun `body and optional parameter`() { test( """ /** * @param x Test */ fun func(@LikelyBody x: Int = 0) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, BODY_TEXT) assertContains(parameters, OPTIONAL_TEXT) assertTrue(containsAnchor(it, Anchors.LIKELY_BODY)) assertTrue(containsAnchor(it, Anchors.OPTIONAL)) } } @Test fun `likely named and optional parameter`() { test( """ /** * @param x Test */ fun func(@LikelyNamed x: Int = 0) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, NAMED_TEXT) assertContains(parameters, OPTIONAL_TEXT) assertTrue(containsAnchor(it, Anchors.LIKELY_NAMED)) assertTrue(containsAnchor(it, Anchors.OPTIONAL)) } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/DocumentTypeConstraintsTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.document.DocumentType import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertFalse /** * Tests for name transformation in Dokka via `@Name`. */ class DocumentTypeConstraintsTransformerTest : QuarkdocDokkaTest( imports = listOf( DocumentType::class, OnlyForDocumentType::class, Name::class, ), stringImports = listOf( NotForDocumentType::class.qualifiedName!!, ), ) { private fun assertContainsNormalGeneration(output: String) { assertContains(output, "Paragraph 1.") assertContains(output, "Paragraph 2.") assertContains(output, "Return") } @Test fun `no constraint`() { test( """ /** * Paragraph 1. * * Paragraph 2. * * @return test */ fun func() = Unit """.trimIndent(), "func", ) { assertContainsNormalGeneration(it) assertFalse("Target" in it) } } @Test fun `only for type`() { test( """ /** * Paragraph 1. * * Paragraph 2. * * @return test */ @OnlyForDocumentType(DocumentType.PAGED) fun func() = Unit """.trimIndent(), "func", ) { assertContainsNormalGeneration(it) assertContains(it, "Target") assertContains(it, "paged") } } @Test fun `only for two types`() { test( """ /** * Paragraph 1. * * Paragraph 2. * * @return test */ @OnlyForDocumentType(DocumentType.PAGED, DocumentType.SLIDES) fun func() = Unit """.trimIndent(), "func", ) { assertContainsNormalGeneration(it) assertContains(it, "Target") assertContains(it, "paged") assertContains(it, "slides") } } @Test fun `combined with renaming`() { test( """ /** * Paragraph 1. * * Paragraph 2. * * @return test */ @Name("abc") @OnlyForDocumentType(DocumentType.PAGED) fun oldFunc() = Unit """.trimIndent(), "abc", ) { assertContainsNormalGeneration(it) assertContains(it, "Target") assertContains(it, "paged") assertContains(it, "abc") assertFalse("(?<!/)oldFunc".toRegex() in it) } } @Test fun `not for type`() { test( """ /** * Paragraph 1. * * Paragraph 2. * * @return test */ @NotForDocumentType(DocumentType.PAGED) fun func() = Unit """.trimIndent(), "func", ) { assertContainsNormalGeneration(it) assertContains(it, "Target") assertContains(it, "plain") assertContains(it, "slides") assertFalse("paged" in it) } } @Test fun `not for two types`() { test( """ @NotForDocumentType(DocumentType.PAGED, DocumentType.SLIDES) fun func() = Unit """.trimIndent(), "func", ) { assertContains(it, "Target") assertContains(it, "plain") assertFalse("slides" in it) assertFalse("paged" in it) } } @Test fun `for all types`() { test( """ @NotForDocumentType() fun func() = Unit """.trimIndent(), "func", ) { assertContains(it, "Target") assertContains(it, "plain") assertContains(it, "slides") assertContains(it, "paged") } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/EnumParameterEntryListerTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.document.DocumentType import kotlin.test.Test import kotlin.test.assertContains /** * Tests for lister of enum entries for an enum parameter. */ class EnumParameterEntryListerTransformerTest : QuarkdocDokkaTest( imports = listOf(DocumentType::class), ) { @Test fun `enum parameter from same module`() { test( """ /** * @param x Test */ fun func(x: DocumentType) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, "Values") assertContains(parameters, "plain") assertContains(parameters, "slides") assertContains(parameters, "paged") } } @Test fun `enum parameter from same module with other parameter`() { test( """ /** * @param x Test 1 * @param y Test 2 */ fun func(x: DocumentType, y: Int) = Unit """.trimIndent(), "func", ) { val parameters = getParametersTable(it).text() assertContains(parameters, "x") assertContains(parameters, "Test 1") assertContains(parameters, "Values") assertContains(parameters, "plain") assertContains(parameters, "slides") assertContains(parameters, "paged") assertContains(parameters, "y") assertContains(parameters, "Test 2") } } // Could not find a way to unit-test enums from the `core` module. } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/LikelyChainedTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.quarkdoc.reader.anchors.Anchors import com.quarkdown.quarkdoc.reader.anchors.AnchorsHtml import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertFalse import kotlin.test.assertTrue private const val CHAINING_TEXT = "Chaining" /** * Tests for the *Chaining* section transformer. */ class LikelyChainedTransformerTest : QuarkdocDokkaTest( stringImports = listOf(LikelyChained::class.qualifiedName!!, LikelyChained::class.qualifiedName!!), stringPaths = listOf(LikelyChained::class.java.packageName + ".QuarkdocAnnotations"), ) { private fun containsAnchor(html: String) = AnchorsHtml.toAnchorAttribute(Anchors.LIKELY_CHAINED) in html @Test fun `not chained`() { test( """ /** * */ fun func(a: Int, b: String) = Unit """.trimIndent(), "func", ) { assertFalse(CHAINING_TEXT in it) assertFalse(containsAnchor(it)) } } @Test fun `chained, two parameters`() { test( """ /** * */ @LikelyChained fun func(a: Int, b: String) = Unit """.trimIndent(), "func", ) { assertContains(it, CHAINING_TEXT) assertTrue(containsAnchor(it)) assertTrue(containsAnchor(it)) assertContains(getText(it), "Int::func b:{String}") } } @Test fun `chained, one parameter`() { test( """ /** * */ @LikelyChained fun func(a: Int) = Unit """.trimIndent(), "func", ) { assertContains(it, CHAINING_TEXT) assertTrue(containsAnchor(it)) assertContains(getText(it), "Int::func") } } @Test fun `chained, optional parameter`() { test( """ /** * */ @LikelyChained fun func(a: Int, b: String? = null) = Unit """.trimIndent(), "func", ) { assertContains(it, CHAINING_TEXT) assertTrue(containsAnchor(it)) assertContains(getText(it), "Int::func b:{String?}") // Default value is not shown. } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/ModuleTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.value.VoidValue import com.quarkdown.quarkdoc.dokka.transformers.module.QuarkdownModulesStorage import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals /** * Tests for synthetic module creation in Dokka. */ class ModuleTransformerTest : QuarkdocDokkaTest( imports = listOf(QuarkdownModule::class, VoidValue::class), stringImports = listOf(QuarkdownModule::class.java.packageName + ".*"), ) { @Test fun `two modules`() { val sources = mapOf( "M1.kt" to """ val Module1: QuarkdownModule = moduleOf(::aFunction) fun aFunction() = VoidValue """.trimIndent(), "M2.kt" to """ val Module2: QuarkdownModule = moduleOf(::bFunction) fun bFunction() = VoidValue """.trimIndent(), ) test( sources, outModule = "Module1", outName = "a-function", ) { assertEquals(2, QuarkdownModulesStorage.moduleCount) assertContains(getSignature(it), "aFunction") } } @Test fun `two modules and leftover file`() { val sources = mapOf( "M1.kt" to """ val Module1: QuarkdownModule = moduleOf(::aFunction) fun aFunction() = VoidValue """.trimIndent(), "M2.kt" to """ val Module2: QuarkdownModule = moduleOf(::bFunction) fun bFunction() = VoidValue """.trimIndent(), "leftover.kt" to "object leftover {}", ) test( sources, outName = "leftover/index", ) { // No error = file exists } } @Test fun `five modules, package list`() { val moduleCount = 5 val sources = (1..moduleCount).associate { "M$it.kt" to """ val Module$it: QuarkdownModule = moduleOf(::someFunction$it) fun someFunction$it() = VoidValue """.trimIndent() } test( sources, outName = "root/package-list", autoPath = false, ) { assertEquals(moduleCount, QuarkdownModulesStorage.moduleCount) assertContains(it, rootPackage) for (i in 1..moduleCount) { assertContains(it, "$rootPackage.module.Module$i") assertContains(it, "$rootPackage/-module$i.html") assertContains(it, "$rootPackage.module.Module$i/some-function$i.html") } } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/NameTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.reflect.annotation.Name import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse /** * Tests for name transformation in Dokka via `@Name`. */ class NameTransformerTest : QuarkdocDokkaTest(imports = listOf(Name::class)) { @Test fun `no name transformation`() { test( "fun someFunction() = Unit", "some-function", ) { assertContains(it, "someFunction") } } @Test fun `function name transformation`() { test( """ @Name("newname") fun someFunction() = Unit """.trimIndent(), "newname", ) { assertContains(it, "newname") assertFalse("(?<!pageIds=\"root::$rootPackage//)someFunction".toRegex() in it) } } @Test fun `parameter name transformation`() { test( """ fun someFunction(@Name("newname") oldParam: String) = Unit """.trimIndent(), "some-function", ) { assertContains(it, "newname") assertFalse("oldParam" in it) } } @Test fun `function and parameter name transformation`() { test( """ @Name("newfuncname") fun someFunction(@Name("newparam1") oldParam1: String, @Name("newparam2") oldParam2: String) = Unit """.trimIndent(), "newfuncname", ) { assertContains(it, "newfuncname") assertContains(it, "newparam1") assertContains(it, "newparam2") } } @Test fun `parameter name transformation with doc`() { test( """ /** * @param oldParam the parameter */ fun someFunction(@Name("newname") oldParam: String) = Unit """.trimIndent(), "some-function", ) { val parameters = getParametersTable(it) assertContains(parameters.text(), "newname") assertContains(parameters.text(), "the parameter") } } @Test fun `parameter name transformation with reference`() { test( """ /** * The parameter is [oldParam]. */ fun someFunction(@Name("newname") oldParam: String) = Unit """.trimIndent(), "some-function", ) { assertEquals("The parameter is newname.", getParagraph(it)) } } @Test fun `see-also function with transformed name`() { test( """ @Name("newname") fun someFunction() = Unit /** * @see someFunction */ fun anotherFunction() = Unit """.trimIndent(), "another-function", ) { assertEquals("newname", getSeeAlsoTable(it).text()) } } @Test fun `referenced function with transformed name`() { test( """ @Name("newname") fun someFunction() = Unit /** * The function is [someFunction]. */ fun anotherFunction() = Unit """.trimIndent(), "another-function", ) { assertEquals("The function is newname.", getParagraph(it)) } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/QuarkdocDokkaTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.log.Log import com.quarkdown.quarkdoc.dokka.transformers.enumeration.EnumStorage import com.quarkdown.quarkdoc.dokka.transformers.module.QuarkdownModulesStorage import com.quarkdown.quarkdoc.dokka.transformers.name.RenamingsStorage import org.jetbrains.dokka.base.testApi.testRunner.BaseAbstractTest import org.jetbrains.dokka.testApi.logger.TestLogger import org.jetbrains.dokka.utilities.DokkaConsoleLogger import org.jetbrains.dokka.utilities.LoggingLevel import org.jsoup.Jsoup import utils.TestOutputWriterPlugin import java.io.File import kotlin.reflect.KClass import kotlin.test.BeforeTest private const val SOURCE_DIR = "src/main/kotlin" private const val SOURCE_ROOT = "$SOURCE_DIR/test/Test.kt" private val CORE_SOURCE_DIR = File("../quarkdown-core/src/main/kotlin").absolutePath /** * @return [this] class to a path in the source tree */ private fun KClass<*>.path(parent: String = CORE_SOURCE_DIR): String { val path = qualifiedName!!.replace(".", "/") return "$parent/$path.kt" } /** * @return [this] package string to a path in the source tree */ private fun String.path(parent: String = CORE_SOURCE_DIR): String { val path = replace(".", "/") return "$parent/$path.kt" } /** * Base class for Dokka-based Quarkdoc tests. * @param rootPackage the root package for the test source */ open class QuarkdocDokkaTest( protected val rootPackage: String = "test", private val imports: List<KClass<*>> = emptyList(), private val stringImports: List<String> = emptyList(), private val stringPaths: List<String> = emptyList(), ) : BaseAbstractTest(logger = TestLogger(DokkaConsoleLogger(LoggingLevel.WARN))) { @BeforeTest fun setUp() { RenamingsStorage.clear() EnumStorage.clear() QuarkdownModulesStorage.clear() } private fun createConfiguration(sourcePaths: List<String>) = dokkaConfiguration { sourceSets { sourceSet { sourceRoots = sourcePaths + imports.map { it.path() } + stringPaths.map { it.path() } } } } private fun createFullSource( rootPath: String, rootSource: String, ): String = buildString { append("/").append(rootPath).append("\n") append("package ").append(rootPackage).append("\n") (stringImports + imports.map { it.qualifiedName }).forEach { append("import ").append(it).append("\n") } append(rootSource) } /** * Tests the output of a given source file. * @param source the source code to test * @param outName the name of the output file, without extension * @param outModule the name of the module to test, or null for the root module * @param autoPath if true, the output path is automatically generated based on [outName] and [outModule]. * @param block action to execute with the output content. */ protected fun test( sources: Map<String, String>, outName: String, outModule: String? = null, autoPath: Boolean = true, block: (String) -> Unit, ) { val unifiedSource = sources.asSequence().joinToString(separator = "\n\n") { (path, source) -> createFullSource(path, source) } val writerPlugin = TestOutputWriterPlugin() testInline( unifiedSource, createConfiguration(sources.keys.toList()), pluginOverrides = listOf(QuarkdocDokkaPlugin(), writerPlugin), ) { renderingStage = { _, _ -> Log.info( writerPlugin.writer.contents.keys .filter { it.startsWith("root/") }, ) val path = if (autoPath) { val directoryPath = rootPackage + (outModule?.let { ".module.$it" } ?: "") "root/$directoryPath/$outName.html" } else { outName } val content = writerPlugin.writer.contents.getValue(path) block(content) } } } /** * Tests the output of a given source file. * * @param source the source code to test * @param outName the name of the output file, without extension * @param autoPath if true, the output path is automatically generated based on [outName]. * @param block action to execute with the output content. */ protected fun test( source: String, outName: String, autoPath: Boolean = true, block: (String) -> Unit, ) = test( mapOf(SOURCE_ROOT to source), outName = outName, autoPath = autoPath, block = block, ) protected fun getText(html: String) = Jsoup .parse(html) .text() /** * @param html the HTML content to parse * @return the function signature as text * @throws IllegalStateException if the signature is not found */ protected fun getSignature(html: String) = Jsoup .parse(html) .select(".content :is(pre, .monospace)") .firstOrNull() ?.wholeText() ?: throw IllegalStateException("Signature not found") /** * @param html the HTML content to parse * @return the main documentation paragraph text * @throws IllegalStateException if the paragraph is not found */ protected fun getParagraph(html: String) = Jsoup .parse(html) .select(".content > .paragraph") .firstOrNull() ?.text() ?: throw IllegalStateException("Paragraph not found") private fun getTable( html: String, name: String, ) = Jsoup .parse(html) .select("h4:contains($name)") .firstOrNull() ?.nextElementSibling() ?: throw IllegalStateException("Table $name not found") /** * @param html the HTML content to parse * @return the parameters table element * @throws IllegalStateException if the table is not found */ protected fun getParametersTable(html: String) = getTable(html, "Parameters") /** * @param html the HTML content to parse * @return the see-also table element * @throws IllegalStateException if the table is not found */ protected fun getSeeAlsoTable(html: String) = getTable(html, "See also") } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/QuarkdownSignatureTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.VoidValue import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for Quarkdown signatures. */ class QuarkdownSignatureTest : QuarkdocDokkaTest( imports = listOf(QuarkdownModule::class, VoidValue::class, DynamicValue::class), stringImports = listOf(QuarkdownModule::class.java.packageName + ".*"), ) { /** * @param functionCode the code of the function to test. Its name must be equal to [functionName] * @param functionName the name of the function to test * @param block the block to execute with the signature as a parameter */ private fun testSignature( functionCode: String, functionName: String = "func", block: (String) -> Unit, ) { val sources = mapOf( "TestModule.kt" to """ val TestModule: QuarkdownModule = moduleOf(::$functionName) $functionCode """.trimIndent(), ) test( sources, outModule = "TestModule", outName = functionName, ) { block(getSignature(it)) } } @Test fun `no parameters`() { testSignature("fun func() = VoidValue") { assertEquals(".func -> Void", it) } } @Test fun `one parameter`() { testSignature("fun func(a: Int) = VoidValue") { assertEquals(".func a:{Int} -> Void", it) } } @Test fun `two parameters`() { testSignature("fun func(a: Int, b: Iterable<DynamicValue>) = VoidValue") { assertEquals(".func a:{Int} b:{Iterable<Dynamic>} -> Void", it) } } @Test fun `default value`() { testSignature("fun func(a: Int = 0) = VoidValue") { assertEquals(".func a:{Int = 0} -> Void", it) } } @Test fun `line breaking, same length`() { testSignature("fun func(a: Int, b: Int, c: Int) = VoidValue") { assertEquals( """ .func a:{Int} b:{Int} c:{Int} -> Void """.trimIndent(), it, ) } } @Test fun `line breaking, different length`() { testSignature("fun func(abcd: Int, ef: String, ghijkl: Int) = VoidValue") { assertEquals( """ .func abcd:{Int} ef:{String} ghijkl:{Int} -> Void """.trimIndent(), it, ) } } @Test fun `line breaking, different length, out of bounds`() { testSignature("fun func(abcd: Int, ef: String, ghijklmnopqrst: Int) = VoidValue") { assertEquals( """ .func abcd:{Int} ef:{String} ghijklmnopqrst:{Int} -> Void """.trimIndent(), it, ) } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/SuppressInjectedTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.reflect.annotation.Injected import kotlin.test.Test import kotlin.test.assertContains /** * Tests for name transformation in Dokka via `@Name`. */ class SuppressInjectedTransformerTest : QuarkdocDokkaTest(imports = listOf(Injected::class)) { @Test fun `injected single parameter`() { test( "fun someFunction(@Injected x: Int) = Unit", "some-function", ) { assertContains(getSignature(it), "fun someFunction()") } } @Test fun `injected first parameter`() { test( "fun someFunction(@Injected x: Int, y: Int) = Unit", "some-function", ) { assertContains(getSignature(it), "fun someFunction(y: Int)") } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/ValueTypeTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.VoidValue import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** * Tests for renaming [com.quarkdown.core.function.value.Value] subclasses in Dokka. */ class ValueTypeTransformerTest : QuarkdocDokkaTest( imports = listOf( NumberValue::class, IterableValue::class, GeneralCollectionValue::class, ObjectValue::class, DictionaryValue::class, VoidValue::class, Value::class, OutputValue::class, ), ) { @Test fun number() { test( "fun number() = NumberValue(10)", "number", ) { assertContains(getSignature(it), "Number") assertFalse("NumberValue" in it) } } @Test fun iterable() { test( "fun iterable(): IterableValue<*> = GeneralCollectionValue(listOf())", "iterable", ) { assertContains(getSignature(it), "Iterable") assertFalse("IterableValue" in it) } } @Test fun `object`() { test( "fun obj() = ObjectValue(10)", "obj", ) { val signature = getSignature(it) println(signature) assertContains(signature, "Int") assertTrue(signature.endsWith("Int")) assertFalse("ObjectValue" in it) } } @Test fun any() { test( "fun any(): OutputValue<*> = NumberValue(10)", "any", ) { println(getSignature(it)) assertTrue(getSignature(it).endsWith("Any")) assertFalse("OutputValue" in it) } } @Test fun `iterable of any`() { test( "fun iterableOfAny(): IterableValue<OutputValue<*>> = GeneralCollectionValue(listOf())", "iterable-of-any", ) { assertTrue(getSignature(it).endsWith("Iterable<Any>")) assertFalse("OutputValue" in it) } } private fun countOccurrences( string: String, substring: String, ): Int { var count = 0 var index = string.indexOf(substring) while (index != -1) { count++ index = string.indexOf(substring, index + substring.length) } return count } @Test fun `iterable of any, in parameter`() { test( "fun iterableOfAnyParameter(iterable: Iterable<OutputValue<*>>): IterableValue<OutputValue<*>>" + " = GeneralCollectionValue(iterable)", "iterable-of-any-parameter", ) { val signature = getSignature(it) assertEquals(2, countOccurrences(signature, "Iterable<Any>")) assertFalse("OutputValue" in signature) } } @Test fun `nullable iterable of any, in parameter`() { test( "fun nullableIterableOfAnyParameter(iterable: Iterable<OutputValue<*>>?): IterableValue<OutputValue<*>>" + " = GeneralCollectionValue(iterable)", "nullable-iterable-of-any-parameter", ) { val signature = getSignature(it) println(signature) assertEquals(2, countOccurrences(signature, "Iterable<Any>")) assertFalse("OutputValue" in signature) } } @Test fun map() { test( "fun map(map: Map<String, OutputValue<*>>) = VoidValue", "map", ) { assertTrue("Map<String, Any>" in getSignature(it)) } } @Test fun `doc authors`() { test( """ fun docAuthors(authors: Map<String, DictionaryValue<OutputValue<String>>>? = null) = VoidValue """.trimIndent(), "doc-authors", ) { println(getSignature(it)) assertTrue("Map<String, Dictionary<Any>>" in getSignature(it)) } } } ================================================ FILE: quarkdown-quarkdoc/src/test/kotlin/com/quarkdown/quarkdoc/dokka/WikiLinkTransformerTest.kt ================================================ package com.quarkdown.quarkdoc.dokka import com.quarkdown.quarkdoc.dokka.page.WIKI_ROOT import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertFalse /** * Tests for the `@wiki` documentation tag. */ class WikiLinkTransformerTest : QuarkdocDokkaTest() { @Test fun `no wiki`() { test( """ /** * */ fun func() = Unit """.trimIndent(), "func", ) { assertFalse("Wiki page" in it) } } @Test fun wiki() { test( """ /** * @wiki home */ fun func() = Unit """.trimIndent(), "func", ) { assertContains(it, "Wiki page") assertContains(it, WIKI_ROOT + "home") } } @Test fun `wiki with escape`() { test( """ /** * @wiki home: page */ fun func() = Unit """.trimIndent(), "func", ) { assertContains(it, "Wiki page") assertContains(it, WIKI_ROOT + "home%3A+page") } } } ================================================ FILE: quarkdown-quarkdoc-reader/README.md ================================================ # quarkdoc-reader This module contains tools for scraping Quarkdown documentation generated by [quarkdoc](../quarkdown-quarkdoc). ================================================ FILE: quarkdown-quarkdoc-reader/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation(kotlin("test")) implementation("org.jsoup:jsoup:1.22.1") } tasks.test { useJUnitPlatform() } ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/DocsContentExtractor.kt ================================================ package com.quarkdown.quarkdoc.reader /** * Extractor of content of a documentation resource. * @see com.quarkdown.quarkdoc.reader.dokka.DokkaHtmlContentExtractor */ interface DocsContentExtractor { /** * @return the extracted main content, if available */ fun extractContent(): String? /** * @return the function data that this documentation resource describes, if it is about a function */ fun extractFunctionData(): DocsFunction? } ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/DocsFunction.kt ================================================ package com.quarkdown.quarkdoc.reader /** * A function in a documentation file. * @param name the name of the function * @param parameters the parameters of the function * @param isLikelyChained whether the function is likely to be chained */ data class DocsFunction( val name: String, val parameters: List<DocsParameter>, val isLikelyChained: Boolean, ) /** * A function parameter in a documentation file. * @param name the name of the parameter * @param description the description of the parameter, possibly in HTML format * @param isOptional whether the parameter is optional * @param isLikelyNamed whether the parameter is likely to be passed as a named parameter * @param isLikelyBody whether the parameter is likely to be passed as a body parameter * @param allowedValues the allowed enum values for the parameter, or `null` if not applicable */ data class DocsParameter( val name: String, val description: String, val isOptional: Boolean, val isLikelyNamed: Boolean, val isLikelyBody: Boolean, val allowedValues: List<String>?, ) ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/DocsWalker.kt ================================================ package com.quarkdown.quarkdoc.reader /** * A scanner of documentation resources. * @param E the type of [DocsContentExtractor] for each scanned resource * @see com.quarkdown.quarkdoc.reader.dokka.DokkaHtmlWalker */ interface DocsWalker<E : DocsContentExtractor> { /** * Scans documentation resources. */ fun walk(): Sequence<Result<E>> /** * Represents a scanned documentation resource. * @param E the type of [DocsContentExtractor] for the resource * @property name the name of the resource (e.g., "lowercase") * @property moduleName the name of the Quarkdown module containing the resource (e.g., "String"), if part of a module * @property extractor a supplier of a corresponding content extractor that can process the resource */ data class Result<E : DocsContentExtractor>( val name: String, val moduleName: String?, val extractor: () -> E, ) { /** * Whether this resource is part of a Quarkdown module of a user library. */ val isInModule: Boolean get() = moduleName != null } } ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/anchors/Anchors.kt ================================================ package com.quarkdown.quarkdoc.reader.anchors /** * Anchors are flag elements that can be used to mark specific sections of documentation. * Anchors are generated by Quarkdoc, and read by Quarkdoc-Reader to extract specific information. */ object Anchors { /** * Name of the anchor for optional parameters. */ const val OPTIONAL = "optional" /** * Name of the anchor for likely named parameters. */ const val LIKELY_NAMED = "likely-named" /** * Name of the anchor for likely body parameters. */ const val LIKELY_BODY = "likely-body" /** * Name of the anchor for likely chained functions. */ const val LIKELY_CHAINED = "likely-chained" /** * Name of the anchor for optionally present enum values of a parameter. */ const val VALUES = "values" } ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/anchors/AnchorsHtml.kt ================================================ package com.quarkdown.quarkdoc.reader.anchors import com.quarkdown.quarkdoc.reader.anchors.AnchorsHtml.ANCHOR_TAG import org.jsoup.nodes.Element /** * Utilities for handling HTML [Anchors]. */ object AnchorsHtml { /** * The HTML tag used for anchors. */ const val ANCHOR_TAG = "a" /** * The attribute used to store the anchor inside [ANCHOR_TAG] elements. */ const val ANCHOR_ATTRIBUTE = "href" /** * Converts an anchor name to the HTML attribute value for [ANCHOR_TAG]. * @param anchor the anchor name * @return the HTML attribute value for the anchor */ fun toAnchorAttribute(anchor: String): String = "#anchor__$anchor" /** * @return the HTML selector for an anchor with the specified [anchor] name */ private fun anchorToSelector(anchor: String): String = "$ANCHOR_TAG[$ANCHOR_ATTRIBUTE='${toAnchorAttribute(anchor)}']" /** * Gets the element of the anchor with the specified [anchor] name within the given [element], if the anchor exists. * @param anchor the anchor name to look for * @param element the element to search in * @return the element of the anchor, or `null` if not found */ fun getAnchorElement( anchor: String, element: Element, ): Element? = element.selectFirst(anchorToSelector(anchor)) /** * Removes all anchors from the given [element]. * @param element the element to strip anchors from * @return a copy of the element without any anchors */ fun stripAnchors(element: Element): Element { val copy = element.clone() copy.select("$ANCHOR_TAG[$ANCHOR_ATTRIBUTE^='${toAnchorAttribute("")}']").forEach { anchor -> anchor.remove() } return copy } } /** * @see [AnchorsHtml.getAnchorElement] */ fun Element.getAnchorElement(anchor: String): Element? = AnchorsHtml.getAnchorElement(anchor, this) /** * @return the next sibling element of the anchor with the specified [anchor] name, or `null` if not found or no next sibling exists * @see [AnchorsHtml.getAnchorElement] */ fun Element.getAnchorNextElement(anchor: String): Element? = getAnchorElement(anchor)?.nextElementSibling() /** * Checks if the given element has an anchor with the specified [anchor] name. * @param anchor the anchor name to check for * @return whether the element contains the anchor, even if nested */ fun Element.hasAnchor(anchor: String): Boolean = getAnchorElement(anchor) != null /** * @see [AnchorsHtml.stripAnchors] */ fun Element.stripAnchors(): Element = AnchorsHtml.stripAnchors(this) ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/dokka/DokkaHtmlContentExtractor.kt ================================================ package com.quarkdown.quarkdoc.reader.dokka import com.quarkdown.quarkdoc.reader.DocsContentExtractor import com.quarkdown.quarkdoc.reader.DocsFunction import com.quarkdown.quarkdoc.reader.DocsParameter import com.quarkdown.quarkdoc.reader.anchors.Anchors import com.quarkdown.quarkdoc.reader.anchors.getAnchorNextElement import com.quarkdown.quarkdoc.reader.anchors.hasAnchor import com.quarkdown.quarkdoc.reader.anchors.stripAnchors import org.jsoup.Jsoup import org.jsoup.nodes.Element private const val PARAMETERS_HEADER = "Parameters" /** * Extractor of content from Dokka-generated HTML files. */ class DokkaHtmlContentExtractor( private val html: String, ) : DocsContentExtractor { override fun extractContent(): String? = Jsoup .parse(html) .selectFirst("#main .content") ?.apply { // Removes copy buttons from code blocks. select(".top-right-position:has(.copy-icon)").remove() }?.outerHtml() override fun extractFunctionData(): DocsFunction? { val main = Jsoup .parse(html) .selectFirst("#main > .main-content") ?.takeIf { it.attr("data-page-type") == "member" } ?: return null return DocsFunction( name = main.selectFirst("h1")?.text() ?: "x", parameters = extractFunctionParameters(main), isLikelyChained = main.hasAnchor(Anchors.LIKELY_CHAINED), ) } /** * Converts a row of the parameters table into a [DocsParameter]. */ private fun rowToParameter(row: Element): DocsParameter? { val name = row.children().firstOrNull()?.text() ?: return null val content = row.selectFirst(".title") return DocsParameter( name = name, description = content?.stripAnchors()?.html() ?: "", isOptional = row.hasAnchor(Anchors.OPTIONAL), isLikelyNamed = row.hasAnchor(Anchors.LIKELY_NAMED), isLikelyBody = row.hasAnchor(Anchors.LIKELY_BODY), allowedValues = row .getAnchorNextElement(Anchors.VALUES) ?.select("li") ?.map { it.text() }, ) } private fun extractFunctionParameters(document: Element): List<DocsParameter> { val table = document .select("h4:contains($PARAMETERS_HEADER)") .firstOrNull() ?.nextElementSibling() ?: return emptyList() return table .getElementsByClass("main-subrow") .mapNotNull(::rowToParameter) } } ================================================ FILE: quarkdown-quarkdoc-reader/src/main/kotlin/com/quarkdown/quarkdoc/reader/dokka/DokkaHtmlWalker.kt ================================================ package com.quarkdown.quarkdoc.reader.dokka import com.quarkdown.quarkdoc.reader.DocsWalker import java.io.File /** * A directory with this name is a Quarkdown module. */ private const val MODULE_DIR_NAME = "module" /** * Recursive walker of Dokka HTML files. */ class DokkaHtmlWalker( private val root: File, ) : DocsWalker<DokkaHtmlContentExtractor> { // e.g. com.quarkdown.stdlib.module.String/lowercase.html => String private val File.quarkdownModuleName: String? get() = parentFile.name .split('.') .takeIf { it.getOrNull(it.size - 2) == MODULE_DIR_NAME } ?.lastOrNull() /** * Recursively scans Dokka HTML files in the given root directory. */ override fun walk(): Sequence<DocsWalker.Result<DokkaHtmlContentExtractor>> = root .walkTopDown() .asSequence() .filter { it.isFile } .filter { it.extension == "html" } .filterNot { it.name == "index.html" } .map { file -> DocsWalker.Result( name = file.nameWithoutExtension, moduleName = file.quarkdownModuleName, extractor = { DokkaHtmlContentExtractor(file.readText()) }, ) } } ================================================ FILE: quarkdown-quarkdoc-reader/src/test/kotlin/com/quarkdown/quarkdoc/reader/DokkaReaderTest.kt ================================================ package com.quarkdown.quarkdoc.reader import com.quarkdown.quarkdoc.reader.dokka.DokkaHtmlContentExtractor import com.quarkdown.quarkdoc.reader.dokka.DokkaHtmlWalker import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tests for extracting content from Dokka HTML files. */ class DokkaReaderTest { @Test fun `html extractor`() { val fullHtml = javaClass.getResourceAsStream("/content/lowercase.html")!!.bufferedReader().readText() val extractedHtml = javaClass.getResourceAsStream("/extract/lowercase.html")!!.bufferedReader().readText() fun String.withoutWhitespace(): String = replace("\\s+".toRegex(), "") assertEquals( extractedHtml.withoutWhitespace(), DokkaHtmlContentExtractor(fullHtml) .extractContent() ?.withoutWhitespace(), ) } private fun extractFunctionData(resourceName: String): DocsFunction { val fullHtml = javaClass.getResourceAsStream(resourceName)!!.bufferedReader().readText() return DokkaHtmlContentExtractor(fullHtml).extractFunctionData()!! } @Test fun `simple parameter extractor`() { val function = extractFunctionData("/content/lowercase.html") val parameter = function.parameters.first { it.name == "string" } assertEquals("lowercase", function.name) assertFalse(function.isLikelyChained) assertEquals( "<p class=\"paragraph\">string to convert</p>", parameter.description, ) assertFalse(parameter.isOptional) assertFalse(parameter.isLikelyBody) assertFalse(parameter.isLikelyNamed) assertNull(parameter.allowedValues) } @Test fun `long parameter extractor`() { val function = extractFunctionData("/content/container.html") val backgroundParameter = function.parameters.first { it.name == "background" } val bodyParameter = function.parameters.first { it.name == "body" } assertEquals("container", function.name) assertFalse(function.isLikelyChained) assertEquals( """ <dl> <ul> <li>Optional</li> <li>Likely <a href>named</a></li> </ul> </dl> <p class="paragraph">background color. Transparent if unset</p> """.trimIndent(), backgroundParameter.description.replace("(?<=href)=\".+?\"".toRegex(), ""), ) assertTrue(backgroundParameter.isOptional) assertFalse(backgroundParameter.isLikelyBody) assertTrue(backgroundParameter.isLikelyNamed) assertTrue(bodyParameter.isOptional) assertTrue(bodyParameter.isLikelyBody) assertFalse(bodyParameter.isLikelyNamed) } @Test fun `likely chained function extractor`() { val function = extractFunctionData("/content/isnone.html") assertEquals("isnone", function.name) assertTrue(function.isLikelyChained) } @Test fun `allowed parameter values extractor`() { val function = extractFunctionData("/content/container.html") val parameter = function.parameters.first { it.name == "alignment" } assertContains(parameter.allowedValues!!, "center") } /** * Copies content/{capitalize.html, lowercase.html, uppercase.html, index.html} to a temp directory * in order to simulate the structure of a Dokka-generated module. * @param moduleName the name of the module to create as a subdirectory * @param resourceNames the names of the resources to copy into the module directory * @return the temporary directory */ private fun copyResourcesToTempDir( moduleName: String, resourceNames: List<String>, ): File { val tempDir = createTempDirectory().toFile() val moduleDir = tempDir.resolve("com.quarkdown.stdlib.module.$moduleName").apply { mkdirs() } resourceNames.forEach { name -> javaClass.getResourceAsStream("/content/$name")!!.use { input -> moduleDir.resolve(name).outputStream().use { output -> input.copyTo(output) } } assertTrue(moduleDir.resolve(name).exists()) } return tempDir } @Test fun walker() { val rootDir = copyResourcesToTempDir("String", listOf("lowercase.html", "uppercase.html", "index.html")) val scanner = DokkaHtmlWalker(rootDir) val results = scanner.walk().toList().sortedBy { it.name } assertEquals(2, results.size) results[0].let { assertEquals("lowercase", it.name) assertEquals("String", it.moduleName) assertTrue(it.isInModule) } results[1].let { assertEquals("uppercase", it.name) assertEquals("String", it.moduleName) assertTrue(it.isInModule) } } } ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/capitalize.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>capitalize</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js", "js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if (savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"> </head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.0-SNAPSHOT </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm </button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//capitalize/#kotlin.String/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.String</a><span class="delimiter">/</span><span class="current">capitalize</span></div> <div class="cover "> <h1 class="cover"><span><span>capitalize</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">capitalize</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <p class="paragraph">Capitalizes the first character of a string. Example: <code class="lang-kotlin">hello, world!</code> -&gt;<code class="lang-kotlin">Hello, world!</code></p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">a new string with the first character capitalized</p></span> <h4 class="">Parameters</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>string</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">string to capitalize</p></div> </div> </div> </div> </div> </div> </div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/container.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>container</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//container/#com.quarkdown.core.document.size.Size?#com.quarkdown.core.document.size.Size?#kotlin.Boolean#com.quarkdown.core.misc.color.Color?#com.quarkdown.core.misc.color.Color?#com.quarkdown.core.misc.color.Color?#com.quarkdown.core.document.size.Sizes?#com.quarkdown.core.ast.quarkdown.block.Container.BorderStyle?#com.quarkdown.core.document.size.Sizes?#com.quarkdown.core.document.size.Sizes?#com.quarkdown.core.document.size.Sizes?#com.quarkdown.core.ast.quarkdown.block.Container.Alignment?#com.quarkdown.core.ast.quarkdown.block.Container.TextAlignment?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Size?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Weight?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Style?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Variant?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Decoration?#com.quarkdown.core.ast.quarkdown.inline.TextTransformData.Case?#com.quarkdown.core.ast.quarkdown.block.Container.FloatAlignment?#com.quarkdown.core.ast.MarkdownContent?/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Layout</a><span class="delimiter">/</span><span class="current">container</span></div> <div class="cover "> <h1 class="cover"><span><span>container</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">container</span><span class="token punctuation"> </span><span class="token constant">width</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Size///PointingToDeclaration/">Size</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">height</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Size///PointingToDeclaration/">Size</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">fullwidth</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-boolean/index.html">Boolean</a><span class="token operator"> = </span><span class="token boolean">false</span><span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">foreground</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.misc.color/Color///PointingToDeclaration/">Color</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">background</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.misc.color/Color///PointingToDeclaration/">Color</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">border</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.misc.color/Color///PointingToDeclaration/">Color</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">borderwidth</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Sizes///PointingToDeclaration/">Sizes</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">borderstyle</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.BorderStyle///PointingToDeclaration/">Container.BorderStyle</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">margin</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Sizes///PointingToDeclaration/">Sizes</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">padding</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Sizes///PointingToDeclaration/">Sizes</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">radius</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.document.size/Sizes///PointingToDeclaration/">Sizes</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">alignment</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment///PointingToDeclaration/">Container.Alignment</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">textalignment</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.TextAlignment///PointingToDeclaration/">Container.TextAlignment</span><span class="token operator">?</span><span class="token operator"> = </span>alignment?.let(Container.TextAlignment::fromAlignment)<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">fontsize</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size///PointingToDeclaration/">TextTransformData.Size</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">fontweight</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Weight///PointingToDeclaration/">TextTransformData.Weight</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">fontstyle</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Style///PointingToDeclaration/">TextTransformData.Style</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">fontvariant</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Variant///PointingToDeclaration/">TextTransformData.Variant</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">textdecoration</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration///PointingToDeclaration/">TextTransformData.Decoration</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">textcase</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Case///PointingToDeclaration/">TextTransformData.Case</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">float</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.FloatAlignment///PointingToDeclaration/">Container.FloatAlignment</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">body</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.ast/MarkdownContent///PointingToDeclaration/">MarkdownContent</span><span class="token operator">?</span><span class="token operator"> = </span>null<span class="token punctuation">}</span><br><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Node///PointingToDeclaration/">Node</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><p class="paragraph">A general-purpose container that groups content. Any layout rules (e.g. from <a href="align.html">align</a>, <a href="row.html">row</a>, <a href="column.html">column</a>, <a href="grid.html">grid</a>) are ignored inside this container.</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">the new <span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container///PointingToDeclaration/">Container</span> node</p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>width</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">width of the container. No constraint if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>height</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">height of the container. No constraint if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>fullwidth</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">whether the container should take up the full width of the parent. Overridden by <a href="container.html">width</a>. False if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>foreground</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">text color. Default if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>background</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">background color. Transparent if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>border</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">border color. Default if unset and <a href="container.html">borderwidth</a> is set</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>borderwidth</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">border width. Default if unset and <a href="container.html">border</a> is set</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>borderstyle</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">border style. Normal (solid) if unset and <a href="container.html">border</a> or <a href="container.html">borderwidth</a> is set</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.BorderStyle.NORMAL///PointingToDeclaration/"><code class="lang-kotlin">normal</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.BorderStyle.DASHED///PointingToDeclaration/"><code class="lang-kotlin">dashed</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.BorderStyle.DOTTED///PointingToDeclaration/"><code class="lang-kotlin">dotted</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.BorderStyle.DOUBLE///PointingToDeclaration/"><code class="lang-kotlin">double</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>margin</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">whitespace outside the content. None if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>padding</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">whitespace around the content. None if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>radius</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">corner (and border) radius. None if unset</p></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>alignment</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">alignment of the content. Default if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.Alignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>textalignment</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">alignment of the text. <a href="container.html">alignment</a> if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.TextAlignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.TextAlignment.CENTER///PointingToDeclaration/"><code class="lang-kotlin">center</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.TextAlignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.TextAlignment.JUSTIFY///PointingToDeclaration/"><code class="lang-kotlin">justify</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>fontsize</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">relative font size of the text. Normal if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.TINY///PointingToDeclaration/"><code class="lang-kotlin">tiny</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.SMALL///PointingToDeclaration/"><code class="lang-kotlin">small</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.NORMAL///PointingToDeclaration/"><code class="lang-kotlin">normal</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.MEDIUM///PointingToDeclaration/"><code class="lang-kotlin">medium</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.LARGER///PointingToDeclaration/"><code class="lang-kotlin">larger</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.LARGE///PointingToDeclaration/"><code class="lang-kotlin">large</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Size.HUGE///PointingToDeclaration/"><code class="lang-kotlin">huge</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>fontweight</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">font weight of the text. Normal if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Weight.NORMAL///PointingToDeclaration/"><code class="lang-kotlin">normal</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Weight.BOLD///PointingToDeclaration/"><code class="lang-kotlin">bold</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>fontstyle</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">font style of the text. Normal if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Style.NORMAL///PointingToDeclaration/"><code class="lang-kotlin">normal</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Style.ITALIC///PointingToDeclaration/"><code class="lang-kotlin">italic</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>fontvariant</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">font variant of the text. Normal if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Variant.NORMAL///PointingToDeclaration/"><code class="lang-kotlin">normal</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Variant.SMALL_CAPS///PointingToDeclaration/"><code class="lang-kotlin">smallcaps</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>textdecoration</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">text decoration of the text. None if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.NONE///PointingToDeclaration/"><code class="lang-kotlin">none</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.UNDERLINE///PointingToDeclaration/"><code class="lang-kotlin">underline</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.OVERLINE///PointingToDeclaration/"><code class="lang-kotlin">overline</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.UNDEROVERLINE///PointingToDeclaration/"><code class="lang-kotlin">underoverline</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.STRIKETHROUGH///PointingToDeclaration/"><code class="lang-kotlin">strikethrough</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Decoration.ALL///PointingToDeclaration/"><code class="lang-kotlin">all</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>textcase</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">text case of the text. Normal if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Case.NONE///PointingToDeclaration/"><code class="lang-kotlin">none</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Case.UPPERCASE///PointingToDeclaration/"><code class="lang-kotlin">uppercase</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Case.LOWERCASE///PointingToDeclaration/"><code class="lang-kotlin">lowercase</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.inline/TextTransformData.Case.CAPITALIZE///PointingToDeclaration/"><code class="lang-kotlin">capitalize</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>float</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__optional"></a>Optional</li><li><a href="#anchor__likely-named"></a>Likely <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call">named</a></li></ul></dl><p class="paragraph">floating position of the container within the parent. Not floating if unset</p><h4 class="">Values</h4><a href="#anchor__values"></a><ul><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.FloatAlignment.START///PointingToDeclaration/"><code class="lang-kotlin">start</code></span></li><li><span data-unresolved-link="com.quarkdown.core.ast.quarkdown.block/Container.FloatAlignment.END///PointingToDeclaration/"><code class="lang-kotlin">end</code></span></li></ul></div></div></div></div><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>body</span></span></u></div></span></div><div><div class="title"><dl><ul><li><a href="#anchor__likely-body"></a>Likely a <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call#block-vs-inline-function-calls">body argument</a></li><li><a href="#anchor__optional"></a>Optional</li></ul></dl><p class="paragraph">content to group</p></div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/Container">Container</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/index.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>com.quarkdown.stdlib.module.String</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js", "js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if (savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"> </head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.0-SNAPSHOT </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm </button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="package" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib.module.String////PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><span class="current">com.quarkdown.stdlib.module.String</span></div> <div class="cover "> <h1 class="cover"><span><span>Package-level</span></span> <span><span>declarations</span></span> </h1> </div> <div class="tabbedcontent"> <div class="tabs-section" tabs-section="tabs-section"> <button class="section-tab" data-active="" data-togglable="FUNCTION,EXTENSION_FUNCTION"> Functions </button> </div> <div class="tabs-section-body"> <div data-togglable="FUNCTION"> <h2 class="">Functions</h2> <div class="table"><a data-name="1355455128%2FFunctions%2F742850071" anchor-label="capitalize" id="1355455128%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="capitalize.html"><span><span>capitalize</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="1355455128%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">capitalize</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Capitalizes the first character of a string. Example: <code class="lang-kotlin">hello, world!</code> -&gt;<code class="lang-kotlin">Hello, world!</code></p></div> </div> </div> </div> </div> </div> </div> <a data-name="-1642518616%2FFunctions%2F742850071" anchor-label="concatenate" id="-1642518616%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="concatenate.html"><span><span>concatenate</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="-1642518616%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">concatenate</span><span class="token punctuation"> </span><span class="token constant">a</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">with</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><br><span class="token punctuation"> </span><span class="token punctuation"> </span><span class="token constant">if</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-boolean/index.html">Boolean</a><span class="token operator"> = </span><span class="token boolean">true</span><span class="token punctuation">}</span><br><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Concatenates two strings if a condition is met. Example: <code class="lang-kotlin">Hello, </code> and <code class="lang-kotlin">World!</code> -&gt;<code class="lang-kotlin">Hello, World!</code></p></div> </div> </div> </div> </div> </div> </div> <a data-name="-263652795%2FFunctions%2F742850071" anchor-label="isempty" id="-263652795%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="isempty.html"><span><span>isempty</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="-263652795%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">isempty</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-boolean/index.html">Boolean</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Checks if a string is empty.</p></div> </div> </div> </div> </div> </div> </div> <a data-name="1644349232%2FFunctions%2F742850071" anchor-label="isnotempty" id="1644349232%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="isnotempty.html"><span><span>isnotempty</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="1644349232%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">isnotempty</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-boolean/index.html">Boolean</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Checks if a string is not empty.</p></div> </div> </div> </div> </div> </div> </div> <a data-name="-901121933%2FFunctions%2F742850071" anchor-label="lowercase" id="-901121933%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="lowercase.html"><span><span>lowercase</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="-901121933%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">lowercase</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Converts a string to lowercase. Example: <code class="lang-kotlin">Hello, World!</code> -&gt;<code class="lang-kotlin">hello, world!</code></p></div> </div> </div> </div> </div> </div> </div> <a data-name="64971389%2FFunctions%2F742850071" anchor-label="string" id="64971389%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="string.html"><span><span>string</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="64971389%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">string</span><span class="token punctuation"> </span><span class="token constant">value</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Creates a string. If <a href="string.html">value</a> is delimited by <code class="lang-kotlin">"</code> characters, they are removed and the wrapped string is not trimmed, as opposed to what usually happens through Quarkdown's parser. Example: <code class="lang-kotlin">" Hello, World! "</code> -&gt;<code class="lang-kotlin">Hello, World! </code></p> </div> </div> </div> </div> </div> </div> </div> <a data-name="1789365460%2FFunctions%2F742850071" anchor-label="uppercase" id="1789365460%2FFunctions%2F742850071" data-filterable-set=":quarkdown-stdlib/main"></a> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"> <div><a href="uppercase.html"><span><span>uppercase</span></span></a></div> <span class="anchor-wrapper"><span class="anchor-icon" pointing-to="1789365460%2FFunctions%2F742850071"></span> <div class="copy-popup-wrapper "><span class="copy-popup-icon"></span><span>Link copied to clipboard</span></div> </span></span></div> <div> <div class="title"> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">uppercase</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <div class="brief "><p class="paragraph">Converts a string to uppercase. Example: <code class="lang-kotlin">Hello, World!</code> -&gt; `HELLO, WORLD!</p></div> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/isnone.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>isnone</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js","js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if(savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"></head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.8.0 </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm</button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//isNone/#com.quarkdown.core.function.value.DynamicValue/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.Optionality</a><span class="delimiter">/</span><span class="current">isnone</span></div> <div class="cover "> <h1 class="cover"><span><span>isnone</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"><div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"><div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">isnone</span><span class="token punctuation"> </span><span class="token constant">value</span><span class="token operator">:</span><span class="token punctuation">{</span><span data-unresolved-link="com.quarkdown.core.function.value/Dynamic///PointingToDeclaration/">Dynamic</span><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Boolean///PointingToDeclaration/">Boolean</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">whether <a href="isnone.html">value</a> represents a <a href="none.html">none</a> value</p></span><h4 class="">Parameters</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><u><span><span>value</span></span></u></div></span></div><div><div class="title"><p class="paragraph">value to check</p></div></div></div></div></div><h4 class="">See also</h4><div class="table"><div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"><div class="main-subrow keyValue "><div class=""><span class="inline-flex"><div><a href="none.html"><span><span>none</span></span></a></div></span></div><div></div></div></div></div><span class="kdoc-tag"><h4 class="kdoctag">Chaining</h4><a href="#anchor__likely-chained"></a>This function is designed to be <a href="https://github.com/iamgio/quarkdown/wiki/syntax-of-a-function-call#chaining-calls">chained</a> with other function calls:<div class="sample-container"><pre><code class="block lang-kotlin" theme="idea"><span data-unresolved-link="com.quarkdown.core.function.value/Dynamic///PointingToDeclaration/">Dynamic</span><span class="token punctuation">::</span><span class="token function">isnone</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><span data-unresolved-link="com.quarkdown.core.function.value/Boolean///PointingToDeclaration/">Boolean</span></code></pre><span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span></div></span><span class="kdoc-tag"><h4 class="kdoctag">Wiki page</h4><a href="https://github.com/iamgio/quarkdown/wiki/None">None</a></span></div></div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/lowercase.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>lowercase</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js", "js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if (savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"> </head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.0-SNAPSHOT </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm </button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//lowercase/#kotlin.String/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.String</a><span class="delimiter">/</span><span class="current">lowercase</span></div> <div class="cover "> <h1 class="cover"><span><span>lowercase</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">lowercase</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> </div> <p class="paragraph">Converts a string to lowercase. Example: <code class="lang-kotlin">Hello, World!</code> -&gt;<code class="lang-kotlin">hello, world!</code></p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">a new lowercase string</p></span><h4 class=""> Parameters</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>string</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">string to convert</p></div> </div> </div> </div> </div> </div> </div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/content/uppercase.html ================================================ <!doctype html> <html class="no-js"> <head> <meta name="viewport" content="width=device-width, initial-scale=1" charset="UTF-8"> <title>uppercase</title> <link href="../../images/logo-icon.svg" rel="icon" type="image/svg"> <script>var pathToRoot = "../../";</script> <script>document.documentElement.classList.replace("no-js", "js");</script> <script>const storage = localStorage.getItem("dokka-dark-mode") if (storage == null) { const osDarkSchemePreferred = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches if (osDarkSchemePreferred === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } else { const savedDarkMode = JSON.parse(storage) if (savedDarkMode === true) { document.getElementsByTagName("html")[0].classList.add("theme-dark") } } </script> <script type="text/javascript" src="https://unpkg.com/kotlin-playground@1/dist/playground.min.js" async></script> <script type="text/javascript" src="../../scripts/sourceset_dependencies.js" async></script> <link href="../../styles/style.css" rel="Stylesheet"> <link href="../../styles/main.css" rel="Stylesheet"> <link href="../../styles/prism.css" rel="Stylesheet"> <link href="../../styles/logo-styles.css" rel="Stylesheet"> <link href="../../styles/font-jb-sans-auto.css" rel="Stylesheet"> <link href="../../ui-kit/ui-kit.min.css" rel="Stylesheet"> <script type="text/javascript" src="../../scripts/clipboard.js" async></script> <script type="text/javascript" src="../../scripts/navigation-loader.js" async></script> <script type="text/javascript" src="../../scripts/platform-content-handler.js" async></script> <script type="text/javascript" src="../../scripts/main.js" defer></script> <script type="text/javascript" src="../../scripts/prism.js" async></script> <script type="text/javascript" src="../../ui-kit/ui-kit.min.js" defer></script> <script type="text/javascript" src="../../scripts/symbol-parameters-wrapper_deferred.js" defer></script> <link href="../../images/logo-icon.svg"> <link href="../../styles/stylesheet.css" rel="Stylesheet"> </head> <body> <div class="root"> <nav class="navigation theme-dark" id="navigation-wrapper"> <a class="library-name--link" href="../../index.html"> quarkdown </a> <button class="navigation-controls--btn navigation-controls--btn_toc ui-kit_mobile-only" id="toc-toggle" type="button">Toggle table of contents </button> <div class="navigation-controls--break ui-kit_mobile-only"></div> <div class="library-version" id="library-version">1.0-SNAPSHOT </div> <div class="navigation-controls"> <div class="filter-section filter-section_loading" id="filter-section"> <button class="platform-tag platform-selector jvm-like" data-active="" data-filter=":quarkdown-stdlib/main">jvm </button> <div class="dropdown filter-section--dropdown" data-role="dropdown" id="filter-section-dropdown"> <button class="button button_dropdown filter-section--dropdown-toggle" role="combobox" data-role="dropdown-toggle" aria-controls="platform-tags-listbox" aria-haspopup="listbox" aria-expanded="false" aria-label="Toggle source sets"></button> <ul role="listbox" id="platform-tags-listbox" class="dropdown--list" data-role="dropdown-listbox"> <div class="dropdown--header"><span>Platform filter</span> <button class="button" data-role="dropdown-toggle" aria-label="Close platform filter"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <li role="option" class="dropdown--option platform-selector-option jvm-like" tabindex="0"> <label class="checkbox"> <input type="checkbox" class="checkbox--input" id=":quarkdown-stdlib/main" data-filter=":quarkdown-stdlib/main"> <span class="checkbox--icon"></span> jvm </label> </li> </ul> <div class="dropdown--overlay"></div> </div> </div> <button class="navigation-controls--btn navigation-controls--btn_theme" id="theme-toggle-button" type="button">Switch theme </button> <div class="navigation-controls--btn navigation-controls--btn_search" id="searchBar" role="button">Search in API </div> </div> </nav> <div id="container"> <div class="sidebar" id="leftColumn"> <div class="dropdown theme-dark_mobile" data-role="dropdown" id="toc-dropdown"> <ul role="listbox" id="toc-listbox" class="dropdown--list dropdown--list_toc-list" data-role="dropdown-listbox"> <div class="dropdown--header"> <span> quarkdown </span> <button class="button" data-role="dropdown-toggle" aria-label="Close table of contents"> <i class="ui-kit-icon ui-kit-icon_cross"></i> </button> </div> <div class="sidebar--inner" id="sideMenu"></div> </ul> <div class="dropdown--overlay"></div> </div> </div> <div id="main"> <div class="main-content" data-page-type="member" id="content" pageids="quarkdown-stdlib::com.quarkdown.stdlib//uppercase/#kotlin.String/PointingToDeclaration//742850071"> <div class="breadcrumbs"><a href="../index.html">quarkdown-stdlib</a><span class="delimiter">/</span><a href="index.html">com.quarkdown.stdlib.module.String</a><span class="delimiter">/</span><span class="current">uppercase</span></div> <div class="cover "> <h1 class="cover"><span><span>uppercase</span></span></h1> </div> <div class="platform-hinted " data-platform-hinted="data-platform-hinted"> <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">uppercase</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> <span class="top-right-position"><span class="copy-icon"></span><div class="copy-popup-wrapper popup-to-left"><span class="copy-popup-icon"></span><span>Content copied to clipboard</span></div></span> </div> <p class="paragraph">Converts a string to uppercase. Example: <code class="lang-kotlin">Hello, World!</code> -&gt; `HELLO, WORLD!</p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">a new uppercase string</p></span><h4 class="">Parameters</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>string</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">string to convert</p></div> </div> </div> </div> </div> </div> </div> </div> <div class="footer"> <a href="#content" id="go-to-top-link" class="footer--button footer--button_go-to-top"></a> <span>© 2025 Quarkdown</span> <span class="pull-right"> <span>Generated by </span> <a class="footer--link footer--link_external" href="https://github.com/Kotlin/dokka"> <span>dokka</span> </a> </span> </div> </div> </div> </div> </body> </html> ================================================ FILE: quarkdown-quarkdoc-reader/src/test/resources/extract/lowercase.html ================================================ <div class="content sourceset-dependent-content" data-active="" data-togglable=":quarkdown-stdlib/main"> <div class="sample-container"> <pre><code class="block lang-kotlin" theme="idea"><span class="token punctuation">.</span><span class="token function">lowercase</span><span class="token punctuation"> </span><span class="token constant">string</span><span class="token operator">:</span><span class="token punctuation">{</span><a href="https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-string/index.html">String</a><span class="token punctuation">}</span><span class="token punctuation"> </span><span class="token operator">-&gt; </span><a href="../../quarkdown-core/com.quarkdown.core.function.value/-string/index.html">String</a></code></pre> </div> <p class="paragraph">Converts a string to lowercase. Example: <code class="lang-kotlin">Hello, World!</code> -&gt;<code class="lang-kotlin">hello, world!</code></p><span class="kdoc-tag"><h4 class="">Return</h4><p class="paragraph">a new lowercase string</p></span><h4 class=""> Parameters</h4> <div class="table"> <div class="table-row" data-filterable-current=":quarkdown-stdlib/main" data-filterable-set=":quarkdown-stdlib/main"> <div class="main-subrow keyValue "> <div class=""><span class="inline-flex"><div><u><span><span>string</span></span></u></div></span> </div> <div> <div class="title"><p class="paragraph">string to convert</p></div> </div> </div> </div> </div> </div> ================================================ FILE: quarkdown-server/README.md ================================================ # server This module contains code for Quarkdown's local webserver, which serves static files and allows for WebSockets-based live preview. Endpoints: - `/` for static files, relative to the origin directory; - `/live/[file]` for wrapping HTML files with live preview capabilities; - `/reload` for WebSocket connections to notify clients of file changes. For architectural details, see [Inside Quarkdown - How does live preview work?](https://quarkdown.com/wiki/inside-live-preview). ================================================ FILE: quarkdown-server/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation(kotlin("test")) implementation(project(":quarkdown-core")) implementation(project(":quarkdown-interaction")) val ktorVersion = "3.4.0" implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("io.ktor:ktor-server-websockets:$ktorVersion") implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("org.slf4j:slf4j-simple:2.0.17") } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/LocalFileWebServer.kt ================================================ package com.quarkdown.server import com.quarkdown.interaction.os.OsUtils import com.quarkdown.server.endpoints.LivePreviewEndpoint import com.quarkdown.server.endpoints.ReloadEndpoint import com.quarkdown.server.stop.KtorStoppableAdapter import com.quarkdown.server.stop.Stoppable import io.ktor.server.application.ServerReady import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.http.content.staticFiles import io.ktor.server.netty.Netty import io.ktor.server.routing.get import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.pingPeriod import io.ktor.server.websocket.timeout import io.ktor.server.websocket.webSocket import java.io.File import kotlin.time.Duration.Companion.seconds /** * Loopback address used by the server and all clients. * On Windows, `localhost` may resolve to `::1` (IPv6) and cause connection timeouts, * so the explicit IPv4 loopback (`127.0.0.1`) is used instead. */ val SERVER_HOST: String = OsUtils.dependent( windows = { "127.0.0.1" }, unix = { "localhost" }, ) /** * Web server that: * - Serves local file at [targetFile]; * - Supports live preview of HTML files at `/live/{file...}` ([LivePreviewEndpoint]); * - Supports live-reloading via WebSockets at `/reload` ([ReloadEndpoint]). * @param targetFile file to serve */ class LocalFileWebServer( private val targetFile: File, ) : Server { private val livePreview = LivePreviewEndpoint(targetFile) private val reload = ReloadEndpoint() /** * Starts the server on [port]. * @throws IllegalArgumentException if [targetFile] does not exist */ override fun start( port: Int, wait: Boolean, onReady: (Stoppable) -> Unit, ) { if (!targetFile.exists()) { throw IllegalArgumentException("Cannot start web server for non-existing file: $targetFile") } embeddedServer(Netty, port) { install(WebSockets) { pingPeriod = 10.seconds timeout = 15.seconds maxFrameSize = Long.MAX_VALUE } monitor.subscribe(ServerReady) { onReady(KtorStoppableAdapter(this)) } routing { // Serves the target file directly at the root path. staticFiles(ServerEndpoints.ROOT, targetFile) // Serves files for live preview. get(ServerEndpoints.LIVE_PREVIEW + "/{file...}") { livePreview.handleRequest(call, port) } // WebSocket endpoint for reloading live previews. webSocket(ServerEndpoints.RELOAD_LIVE_PREVIEW) { reload.handleRequest(this) } } }.start(wait = wait) } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/Server.kt ================================================ package com.quarkdown.server import com.quarkdown.server.stop.Stoppable /** * Abstract representation of a server. */ interface Server { /** * Starts the server on [port]. * @param port port to start the server on * @param wait if true, blocks the current thread until the server is stopped * @param onReady callback called when the server is ready to accept requests, with the application as argument */ fun start( port: Int, wait: Boolean = false, onReady: (Stoppable) -> Unit = {}, ) } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/ServerEndpoints.kt ================================================ package com.quarkdown.server /** * Endpoints of the local web server. * @see LocalFileWebServer */ object ServerEndpoints { /** * Root endpoint, which serves static files. */ const val ROOT = "/" /** * Endpoint for live preview, which supports live reloading. * * `/live/file.html` serves the file `file.html` with live reloading support. */ const val LIVE_PREVIEW = "/live" /** * Endpoint to trigger a reload of the live preview. * * Sending a message to this endpoint will trigger a reload in all connected clients. */ const val RELOAD_LIVE_PREVIEW = "/reload" } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/ServerFreePortScanner.kt ================================================ package com.quarkdown.server import com.quarkdown.server.stop.Stoppable import kotlinx.io.IOException /** * Scans for a free port and starts the server on it. * @param server server to start */ class ServerFreePortScanner( private val server: Server, ) { /** * Attempts to start the server on a free port. * @param startingPort port to start from * @param onReady callback called when the server is ready to accept requests, with the application and port as arguments */ fun attemptStartUntilPortAvailable( startingPort: Int, onReady: (Stoppable, port: Int) -> Unit, ) { var port = startingPort while (port <= MAX_PORT) { try { server.start(port) { stoppable -> onReady(stoppable, port) } return } catch (e: IOException) { port++ } } throw IOException("No available port found in range $startingPort..$MAX_PORT") } companion object { /** * Maximum valid port number. */ private const val MAX_PORT = 65535 } } /** * @return a [ServerFreePortScanner] for this server */ fun Server.withScanner() = ServerFreePortScanner(this) ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/BrowserLauncher.kt ================================================ package com.quarkdown.server.browser import com.quarkdown.server.SERVER_HOST /** * Launcher of a URL in a specific browser. */ interface BrowserLauncher { /** * Indicates whether the browser is valid and can be launched. */ val isValid: Boolean /** * Launches a URL in the specified browser. * @param url URL to launch */ fun launch(url: String) /** * Launches a local server URL in the specified browser. * @param port port to launch * @param endpoint endpoint to launch, defaults to `/` */ fun launchLocal( port: Int, endpoint: String = "/", ) { launch("http://$SERVER_HOST:$port$endpoint") } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/DefaultBrowserLauncher.kt ================================================ package com.quarkdown.server.browser import java.awt.Desktop import java.net.URI /** * Launcher of a URL in the default system browser. * * On platforms where the Java AWT Desktop API supports the BROWSE action (e.g., macOS, most Windows setups), * it is used directly. On platforms where it is not supported (e.g., Linux on Wayland), * this launcher falls back to [XdgBrowserLauncher] if `xdg-open` is available in the system PATH. */ class DefaultBrowserLauncher : BrowserLauncher { /** * Whether the Java AWT Desktop API supports opening URLs on the current platform. */ private val isDesktopSupported: Boolean get() = Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) /** * Fallback launcher using `xdg-open`, used when the Desktop API is not supported. */ private val xdgFallback: XdgBrowserLauncher by lazy { XdgBrowserLauncher() } override val isValid: Boolean get() = isDesktopSupported || xdgFallback.isValid override fun launch(url: String) { when { isDesktopSupported -> { Desktop.getDesktop().browse(URI(url)) } xdgFallback.isValid -> { xdgFallback.launch(url) } else -> { throw UnsupportedOperationException( "Cannot open URL: neither the Desktop API nor xdg-open is available on this platform.", ) } } } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/EnvBrowserLauncher.kt ================================================ package com.quarkdown.server.browser /** * Prefix used to look up browser environment variables. * Example: `BROWSER_CHROME`, `BROWSER_FIREFOX`, etc. */ private const val ENV_PREFIX = "BROWSER_" /** * Launcher of browsers whose path is stored in environment variables (`BROWSER_<env>`) * passing the URL as the first argument to the browser executable. * * @param env the environment variable suffix (e.g., `chrome` for `BROWSER_CHROME`). * @param envLookup function to look up environment variable values. * If different from `System::getenv`, it can be used for testing purposes */ class EnvBrowserLauncher( private val env: String, private val envLookup: (String) -> String?, ) : BrowserLauncher { /** * The name of the environment variable for the browser. */ val envName: String get() = ENV_PREFIX + env.uppercase() /** * The value of the environment variable for the browser, if set. */ private val envValue: String? by lazy { this.envLookup(envName) } override val isValid: Boolean get() = this.envValue.isNullOrBlank().not() override fun launch(url: String) { val browserPath = this.envValue!! val command = arrayOf(browserPath, url) Runtime.getRuntime().exec(command) } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/NoneBrowserLauncher.kt ================================================ package com.quarkdown.server.browser /** * A fake browser launcher that does not perform any action. * This is needed to allow `--browser none` option in the CLI. */ class NoneBrowserLauncher : BrowserLauncher { override val isValid: Boolean get() = true override fun launch(url: String) {} } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/PathBrowserLauncher.kt ================================================ package com.quarkdown.server.browser import java.nio.file.Path /** * Browser launcher that uses a specific file system path to launch the browser. * @param path the file system path to the browser executable */ class PathBrowserLauncher( private val path: Path, ) : BrowserLauncher { override val isValid: Boolean get() = path.toFile().run { exists() && canExecute() } override fun launch(url: String) { val processBuilder = ProcessBuilder(path.toAbsolutePath().toString(), url) processBuilder.start() } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/browser/XdgBrowserLauncher.kt ================================================ package com.quarkdown.server.browser import java.io.File import kotlin.io.path.Path /** * Browser launcher that uses `xdg-open` to open URLs on Linux systems. * * This is typically available on Linux systems with XDG-compliant desktop environments. * `xdg-open` delegates to the user's preferred browser. * * Delegates to [PathBrowserLauncher] if `xdg-open` is found in the system `PATH`. */ class XdgBrowserLauncher : BrowserLauncher { /** * The underlying [PathBrowserLauncher] pointing to the resolved `xdg-open` executable, * or `null` if `xdg-open` is not available in the system `PATH`. */ private val delegate: PathBrowserLauncher? by lazy { System .getenv("PATH") ?.split(File.pathSeparator) ?.firstNotNullOfOrNull { directory -> File(directory, "xdg-open") .takeIf { it.exists() && it.canExecute() } }?.let { PathBrowserLauncher(Path(it.absolutePath)) } } override val isValid: Boolean get() = delegate != null && delegate?.isValid == true override fun launch(url: String) { delegate?.launch(url) ?: throw UnsupportedOperationException( "Cannot open URL: xdg-open is not available in the system PATH.", ) } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/endpoints/LivePreviewEndpoint.kt ================================================ package com.quarkdown.server.endpoints import com.quarkdown.core.template.TemplateProcessor import com.quarkdown.server.SERVER_HOST import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respondFile import io.ktor.server.response.respondText import java.io.File /** * Default file to serve if none is specified */ private const val DEFAULT_FILE = "index.html" private const val TEMPLATE_SOURCE_FILE_PLACEHOLDER = "srcFile" private const val TEMPLATE_SERVER_HOST_PLACEHOLDER = "serverHost" private const val TEMPLATE_SERVER_PORT_PLACEHOLDER = "serverPort" /** * Handler of the live preview endpoint (`/live/<file>`) which serves static files relative to a target file or directory. * Additionally, for HTML files, it serves a wrapper HTML that includes a WebSocket script + iframe for live previewing. * @param origin the root directory from which files are served */ class LivePreviewEndpoint( private val origin: File, ) { /** * Resolves the target file based on the request parameters. * If no specific file is requested, defaults to [DEFAULT_FILE]. * @param call the application call * @return the resolved file, even if it does not exist */ private fun getTargetFile(call: ApplicationCall): File { val segments = call.parameters.getAll("file")?.takeIf { it.isNotEmpty() } ?: listOf(DEFAULT_FILE) val path = segments.joinToString("/") // e.g. file.html or subdir/file.html return origin.resolve(path) } /** * Handles a request to the live preview endpoint, by serving the requested file or a wrapper HTML for live preview. * @param call the application call * @param port the port the server is running on */ suspend fun handleRequest( call: ApplicationCall, port: Int, ) { val file = getTargetFile(call) if (!file.exists() || !file.isFile) { call.respondText("Not Found", status = HttpStatusCode.NotFound) return } when { file.extension.lowercase() == "html" -> { call.respondText(createHtmlWrapperText(file, port), ContentType.Text.Html) } // Non-HTML files are served directly. else -> { call.respondFile(file) } } } private fun createHtmlWrapperText( targetFile: File, serverPort: Int, ): String { // The iframe src is an absolute path from the server root, // which correctly handles both root-level and subdirectory files. val sourceFile = "/${targetFile.relativeTo(origin).invariantSeparatorsPath}" return TemplateProcessor .fromResourceName("/live-preview/wrapper.html.jte", referenceClass = javaClass) .value(TEMPLATE_SOURCE_FILE_PLACEHOLDER, sourceFile) .value(TEMPLATE_SERVER_HOST_PLACEHOLDER, SERVER_HOST) .value(TEMPLATE_SERVER_PORT_PLACEHOLDER, serverPort.toString()) .process() .toString() } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/endpoints/ReloadEndpoint.kt ================================================ package com.quarkdown.server.endpoints import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame import io.ktor.websocket.WebSocketSession import io.ktor.websocket.close import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** * Handler of the reload endpoint (`/reload`) which manages WebSocket connections for live reloading. * * Whenever a client sends a message, it is broadcast to all connected clients. * * A distinction between sender and receiver is not made. * In practice, the sender is Quarkdown CLI and the receivers are the browser clients * that were served by the live preview endpoint (`/live/<file>`). */ class ReloadEndpoint { private val logger: Logger = LoggerFactory.getLogger(ReloadEndpoint::class.java) // Trackers of active connections. private val activeConnections = ConcurrentHashMap<String, Boolean>() private val connectionCounter = AtomicInteger(0) // Shared flow to broadcast messages to all connected clients. // No replay is needed: a newly connected client already has the latest content, // and replaying stale messages would trigger redundant reloads. private val messageResponseFlow = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10) private val sharedFlow = messageResponseFlow.asSharedFlow() /** * Handles the logic for the WebSocket endpoint. * - A new tracked connection is created; * - Incoming messages from a client are broadcast to all connected clients. * @param session the WebSocket session */ suspend fun handleRequest(session: WebSocketSession) { val connectionId = newConnectionId() registerConnection(connectionId) val forwarderJob = session.launch { forwardMessagesToClient(session, connectionId) } try { receiveAndBroadcast(session, connectionId) } catch (e: CancellationException) { logger.debug("WebSocket cancelled: $connectionId") throw e } catch (e: Exception) { logger.error("WebSocket error for $connectionId: ${e.message}") } finally { cleanupConnection(session, connectionId, forwarderJob) } } /** * @return a new unique connection ID */ private fun newConnectionId(): String = "connection-${connectionCounter.incrementAndGet()}" /** * Registers a new active connection. */ private fun registerConnection(connectionId: String) { activeConnections[connectionId] = true logger.info("WebSocket connection established: $connectionId") } /** * Forwards messages from the shared flow to the specific client session, if still active. */ private suspend fun forwardMessagesToClient( session: WebSocketSession, connectionId: String, ) { try { sharedFlow.collect { message -> if (activeConnections.containsKey(connectionId)) { session.send(Frame.Text(message)) logger.debug("Sent message to $connectionId: $message") } } } catch (_: CancellationException) { logger.debug("Forwarder cancelled for $connectionId") } catch (e: Exception) { logger.error("Error sending message to $connectionId: ${e.message}") } } /** * Listens for messages from the client session and broadcasts them to all connected clients. */ private suspend fun receiveAndBroadcast( session: WebSocketSession, connectionId: String, ) { session.incoming.consumeEach { frame -> if (frame is Frame.Text) { val receivedText = frame.readText() logger.info("Received reload request from $connectionId") logger.debug("Broadcasting message to all connections: $receivedText") messageResponseFlow.emit(receivedText) } } } /** * Cleans up resources associated with a connection. */ private suspend fun cleanupConnection( session: WebSocketSession, connectionId: String, forwarderJob: Job, ) { activeConnections.remove(connectionId) logger.info("WebSocket connection closed: $connectionId") try { forwarderJob.cancel() session.close(CloseReason(CloseReason.Codes.NORMAL, "Connection closed")) } catch (e: Exception) { logger.debug("Error during cleanup for $connectionId: ${e.message}") } } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/message/ServerMessage.kt ================================================ package com.quarkdown.server.message /** * A message sent from the client to the server as a WebSocket text frame. */ class ServerMessage { /** * The text content of this message. */ val content: String = "reload" /** * Sends the message to a WebSocket server. * @param session session to use to send the message */ fun send(session: ServerMessageSession) { session.send(this) } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/message/ServerMessageSession.kt ================================================ package com.quarkdown.server.message import com.quarkdown.core.log.Log import com.quarkdown.server.SERVER_HOST import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.endpoint import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.websocket.Frame import io.ktor.websocket.close import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.runBlocking /** * Manages a client WebSocket session to a server. * @param host server hostname * @param port server port to connect to * @param endpoint WebSocket endpoint path segment (without leading slash) */ class ServerMessageSession( private val host: String = SERVER_HOST, private val port: Int, private val endpoint: String, ) { @Volatile private var session: DefaultClientWebSocketSession? = null private val client: HttpClient by lazy { HttpClient(CIO) { engine { endpoint { connectTimeout = 10_000 requestTimeout = 10_000 connectAttempts = 5 } } install(WebSockets) } } /** * Whether a WebSocket session has been established and not yet closed. */ val isConnected: Boolean get() = session != null /** * Initializes (if not already initialized) the WebSocket connection. * @param onReady callback invoked after the connection is successfully opened */ suspend fun init(onReady: suspend () -> Unit = {}) { if (session != null) return try { session = client.webSocketSession("ws://$host:$port/$endpoint") onReady() session!!.incoming.consumeEach { } } catch (e: Exception) { Log.error("WebSocket closed with exception: ${e.message}") } finally { close() } } private suspend fun close() { session?.close() client.close() session = null } /** * Sends a [ServerMessage] as a text frame. * @throws IllegalStateException if the session is not initialized */ fun send(message: ServerMessage) { runBlocking { checkNotNull(session).send(Frame.Text(message.content)) } } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/stop/KtorStoppableAdapter.kt ================================================ package com.quarkdown.server.stop import io.ktor.server.application.Application /** * Adapter for [Stoppable] of a Ktor application. */ class KtorStoppableAdapter( private val application: Application, ) : Stoppable { override fun stop() { application.engine.stop() } } ================================================ FILE: quarkdown-server/src/main/kotlin/com/quarkdown/server/stop/Stoppable.kt ================================================ package com.quarkdown.server.stop /** * A component that can be stopped. */ interface Stoppable { /** * Stops the component. */ fun stop() } ================================================ FILE: quarkdown-server/src/main/resources/live-preview/wrapper.html.jte ================================================ @param String srcFile @param String serverHost @param String serverPort <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Quarkdown Live Preview</title> <style> html, body { margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden; background-color: white; } .frame-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; background-color: white; transform: translateZ(0); } .content-frame { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; margin: 0; padding: 0; overflow: hidden; z-index: 999999; background-color: white; transition: opacity 0.3s ease-in-out; will-change: opacity, transform; transform: translateZ(0); backface-visibility: hidden; -webkit-backface-visibility: hidden; -webkit-transform-style: preserve-3d; transform-style: preserve-3d; } .hidden { opacity: 0; z-index: 999998; pointer-events: none; } .visible { opacity: 1; z-index: 999999; pointer-events: auto; } .debug .frame-container { display: flex; flex-direction: row; } .debug .content-frame { position: relative; width: 50%; border: 2px solid red; } .debug .hidden { opacity: .5; } </style> </head> <body> <!-- Double frame buffer makes preview updates smoother --> <div class="frame-container"> <iframe id="frame-0" class="content-frame visible" src="${srcFile}"></iframe> <iframe id="frame-1" class="content-frame hidden" src="${srcFile}"></iframe> </div> <script data-name="websockets"> /** * Starts a WebSocket connection to the specified server and endpoint. * @param {string} serverUrl - The server URL (e.g., `localhost:8080`). * @param {string} endpoint - The WebSocket endpoint (e.g., `reload`). * @param {function} onMessage - The callback function to handle incoming messages. * @returns {WebSocket} The WebSocket instance. */ function startWebSocket(serverUrl, endpoint, onMessage) { const socket = new WebSocket(`ws://${'$'}{serverUrl}/${'$'}{endpoint}`); socket.addEventListener('open', () => { console.log('Connected to server ' + socket.url); }); socket.addEventListener("message", onMessage); socket.addEventListener('close', (event) => { console.log(`WebSocket closed: Code=${'$'}{event.code}, Reason=${'$'}{event.reason}`); // Automatically reconnect after a brief delay. setTimeout(() => startWebSocket(serverUrl, endpoint, onMessage), 1000); }); socket.addEventListener("error", (error) => { console.error("WebSocket error:", error); }); return socket; } /** * Starts the WebSocket that reload the content when a message is received. */ function startReloadWebSocket(serverUrl) { startWebSocket(serverUrl, 'reload', reload); } </script> <script data-name="messaging"> // Scalable message listener registry, with event type as the key and the handler function as the value. const messageListeners = new Map(); // Listens for messages from the iframe content. window.addEventListener('message', (event) => { const data = event.data; if (!data || data.source !== 'quarkdown') return; const listener = messageListeners.get(data.event); listener?.(data); }); </script> <script data-name="double-buffering"> const NUM_FRAMES = 2; let activeFrameId = 0; let inactiveFrameId = 1; // Stored scroll position, continuously tracked on the active frame. let scrollX = 0; let scrollY = 0; let isScrollStickyToBottom = false; // During a reload, the swap is deferred until the hidden frame is fully ready: // both loaded (onload) and rendered (postRenderingCompleted). // Their relative ordering is unpredictable (postMessage vs onload are separate tasks), // so the swap commits as soon as both signals have arrived. let reloadPending = false; let frameLoaded = false; let frameRendered = false; let swapFallbackTimer = null; /** * Suppresses CSS smooth scrolling inside the given frame, so that * programmatic scroll restoration is always instant. * @param {HTMLIFrameElement} frame - The frame element */ function suppressSmoothScroll(frame) { const doc = frame.contentDocument; if (doc) doc.documentElement.style.scrollBehavior = 'auto'; } // Re-apply after every iframe reload, since the document is replaced. for (let i = 0; i < NUM_FRAMES; i++) { document.getElementById(`frame-${'$'}{i}`).addEventListener('load', function () { suppressSmoothScroll(this); }); } /** * @returns {HTMLIFrameElement} The frame element which is currently active (visible). */ function getActiveFrame() { return document.getElementById(`frame-${'$'}{activeFrameId}`); } /** * @returns {HTMLIFrameElement} The frame element which is currently inactive (hidden). */ function getInactiveFrame() { return document.getElementById(`frame-${'$'}{inactiveFrameId}`); } /** * @returns {boolean} Whether the scroll position is at the very bottom of the page */ function isScrolledToBottom(frame) { const win = frame.contentWindow; if (!win) return false; const scrollableHeight = win.document.body.scrollHeight; const viewportHeight = win.innerHeight; const currentY = win.scrollY || 0; // If content fits in viewport or scrolled to bottom. return scrollableHeight <= viewportHeight || currentY >= scrollableHeight - viewportHeight - 1; } /** * Reads the current scroll position from the given frame into the stored values. * @param {HTMLIFrameElement} frame - The frame to read from */ function captureScrollPosition(frame) { const win = frame.contentWindow; if (!win) return; scrollX = win.scrollX || 0; scrollY = win.scrollY || 0; isScrollStickyToBottom = isScrolledToBottom(frame); } /** * Restores the stored scroll position on the given frame. * If the user was at the bottom, keeps them at the bottom. * @param {HTMLIFrameElement} frame - The frame to scroll */ function restoreScrollPosition(frame) { suppressSmoothScroll(frame); const win = frame.contentWindow; if (!win) return; const y = isScrollStickyToBottom ? win.document.body.scrollHeight : scrollY; win.scrollTo(scrollX, y); } /** * Starts continuously tracking scroll on the given frame, * storing the position for later restoration. * @param {HTMLIFrameElement} frame - The frame to track */ function setupScrollTracking(frame) { const win = frame.contentWindow; if (!win) return; win.onscroll = () => { scrollX = win.scrollX || 0; scrollY = win.scrollY || 0; isScrollStickyToBottom = isScrolledToBottom(frame); }; } /** * Stops scroll tracking on the given frame. * @param {HTMLIFrameElement} frame */ function teardownScrollTracking(frame) { const win = frame.contentWindow; if (win) win.onscroll = null; } /** * Toggles the visibility class of a frame. * @param {HTMLElement} frame - The frame element * @param {boolean} isVisible - Whether the frame should be visible */ function setFrameVisibility(frame, isVisible) { if (isVisible) { frame.classList.remove('hidden'); frame.classList.add('visible'); } else { frame.classList.remove('visible'); frame.classList.add('hidden'); } } /** * Attempts to commit the swap if both readiness signals have arrived. * Does nothing if either the frame hasn't loaded or hasn't rendered yet. */ function maybeCommitSwap() { if (frameLoaded && frameRendered) { commitSwap(); } } /** * Performs the frame swap. Only called when the hidden frame is fully ready * (loaded and rendered), so scroll can be restored accurately on the * still-hidden frame before it becomes visible. */ function commitSwap() { if (!reloadPending) return; clearTimeout(swapFallbackTimer); const outgoing = getActiveFrame(); const incoming = getInactiveFrame(); // Snapshot the scroll position from the outgoing frame at the last possible moment, // so any user scrolling since the reload started is captured. captureScrollPosition(outgoing); // Detach all handlers. teardownScrollTracking(outgoing); teardownScrollTracking(incoming); incoming.onload = null; // Restore scroll on the still-hidden incoming frame. restoreScrollPosition(incoming); // Swap visibility. setFrameVisibility(incoming, true); setFrameVisibility(outgoing, false); // Update IDs. const prev = activeFrameId; activeFrameId = inactiveFrameId; inactiveFrameId = prev; // Start tracking on the new active frame. setupScrollTracking(incoming); // Reset reload state. reloadPending = false; frameLoaded = false; frameRendered = false; } </script> <script data-name="reload"> /** * Reloads the preview using double buffering to prevent flickering. */ function reload() { reloadPending = true; frameLoaded = false; frameRendered = false; clearTimeout(swapFallbackTimer); inactiveFrameId = (activeFrameId + 1) % NUM_FRAMES; const active = getActiveFrame(); const nextFrame = getInactiveFrame(); // Make sure the frame to reload is properly hidden. setFrameVisibility(nextFrame, false); // (#199) If the URL has changed (e.g. different slide, subdocument, etc.), // sync the inactive frame's src before reloading. const activeUrl = active.contentWindow?.location?.href; if (activeUrl && nextFrame.src !== activeUrl) { nextFrame.src = activeUrl; // Setting src triggers a load, so the onload handler below covers this path too. nextFrame.onload = onFrameLoaded; return; } nextFrame.onload = onFrameLoaded; nextFrame?.contentWindow?.location?.reload(); } /** * Signal: the hidden frame's document and subresources have loaded. */ function onFrameLoaded() { frameLoaded = true; // Start a fallback timer for content that never sends postRenderingCompleted. // This must be long enough for the content's rendering pipeline to complete, // especially for paged documents where paged.js layout can take several seconds. swapFallbackTimer = setTimeout(commitSwap, 5000); maybeCommitSwap(); } /** * Signal: the hidden frame's content has finished its async rendering pipeline. * For Quarkdown content this fires after the post-rendering execution queue completes. */ function onFrameRendered() { if (!reloadPending) { // Initial page load (no reload in progress). // Just set up scroll tracking on the active frame. setupScrollTracking(getActiveFrame()); return; } frameRendered = true; maybeCommitSwap(); } messageListeners.set('postRenderingCompleted', onFrameRendered); </script> <script>startReloadWebSocket("${serverHost}:${serverPort}")</script> </body> </html> ================================================ FILE: quarkdown-server/src/main/resources/simplelogger.properties ================================================ org.slf4j.simpleLogger.logFile=System.out org.slf4j.simpleLogger.defaultLogLevel=error org.slf4j.simpleLogger.showThreadName=false org.slf4j.simpleLogger.showLogName=false org.slf4j.simpleLogger.showShortLogName=false org.slf4j.simpleLogger.showDateTime=false ================================================ FILE: quarkdown-server/src/test/kotlin/com/quarkdown/server/LivePreviewEndpointTest.kt ================================================ package com.quarkdown.server import com.quarkdown.server.stop.Stoppable import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import java.io.File import java.nio.file.Files import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for the live preview endpoint (`/live/{file...}`). * * Unlike [LocalFileWebServerTest], the server is backed by a **directory** * so that subdirectory file resolution and static file serving work correctly. */ class LivePreviewEndpointTest { private lateinit var tempDir: File private lateinit var server: LocalFileWebServer private var port: Int = 0 private var serverStoppable: Stoppable? = null @BeforeTest fun setUp() { tempDir = Files.createTempDirectory("live-preview-test").toFile() tempDir.deleteOnExit() // Create a root HTML file. File(tempDir, "test.html").writeText("<html><body>Root</body></html>") // The server is backed by the directory. server = LocalFileWebServer(tempDir) port = findFreePort() val latch = CountDownLatch(1) thread { server.start(port) { stoppable -> serverStoppable = stoppable latch.countDown() } } assertTrue(latch.await(5, TimeUnit.SECONDS), "Server did not start within timeout") } @AfterTest fun tearDown() { serverStoppable?.stop() tempDir.deleteRecursively() } @Test fun `live preview serves HTML wrapper for root file`() = runBlocking { val client = HttpClient(CIO) client.use { client -> val response = client.get("http://localhost:$port/live/test.html") assertEquals(HttpStatusCode.OK, response.status) val body = response.bodyAsText() // The wrapper must contain an iframe whose src is an absolute path from the root. assertTrue(body.contains("src=\"/test.html\""), "Expected iframe src=\"/test.html\" in wrapper") // The wrapper must include the WebSocket script with the correct server port. assertTrue(body.contains("$SERVER_HOST:$port"), "Expected server host and port in WebSocket script") } } @Test fun `live preview serves HTML wrapper for subdirectory file`() = runBlocking { // Create a file inside a subdirectory. val subdir = File(tempDir, "subdir").apply { mkdir() } File(subdir, "page.html").writeText("<html><body>Sub</body></html>") val client = HttpClient(CIO) client.use { client -> val response = client.get("http://localhost:$port/live/subdir/page.html") assertEquals(HttpStatusCode.OK, response.status) val body = response.bodyAsText() // The iframe src must use an absolute path that includes the subdirectory. assertTrue( body.contains("src=\"/subdir/page.html\""), "Expected iframe src=\"/subdir/page.html\" in wrapper", ) } } @Test fun `live preview serves non-HTML files directly`() = runBlocking { val cssContent = "body { color: red; }" File(tempDir, "style.css").writeText(cssContent) val client = HttpClient(CIO) client.use { client -> val response = client.get("http://localhost:$port/live/style.css") assertEquals(HttpStatusCode.OK, response.status) val body = response.bodyAsText() assertEquals(cssContent, body) } } @Test fun `live preview returns 404 for non-existent file`() = runBlocking { val client = HttpClient(CIO) client.use { client -> val response = client.get("http://localhost:$port/live/nonexistent.html") assertEquals(HttpStatusCode.NotFound, response.status) val body = response.bodyAsText() // The endpoint must respond with a human-readable "Not Found" body. assertEquals("Not Found", body) } } /** * Finds a free port by using [ServerFreePortScanner] with a no-op server. */ private fun findFreePort(): Int = ServerFreePortScanner( object : Server { override fun start( port: Int, wait: Boolean, onReady: (Stoppable) -> Unit, ) { onReady( object : Stoppable { override fun stop() {} }, ) } }, ).run { val portDeferred = CompletableDeferred<Int>() attemptStartUntilPortAvailable(8000) { _, port -> portDeferred.complete(port) } runBlocking { portDeferred.await() } } } ================================================ FILE: quarkdown-server/src/test/kotlin/com/quarkdown/server/LocalFileWebServerTest.kt ================================================ package com.quarkdown.server import com.quarkdown.server.stop.Stoppable import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Files import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.thread import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** * Tests for [LocalFileWebServer]. */ class LocalFileWebServerTest { private lateinit var tempDir: File private lateinit var testFile: File private lateinit var server: LocalFileWebServer private var port: Int = 0 private var serverStoppable: Stoppable? = null @BeforeTest fun setUp() { // Create a temporary directory tempDir = Files.createTempDirectory("webserver-test").toFile() tempDir.deleteOnExit() // Create a test file testFile = File(tempDir, "test.html") testFile.writeText( """ <!DOCTYPE html> <html> <head> <title>Test Page</title> </head> <body> <h1>Test Page</h1> <p>This is a test page for the LocalFileWebServer.</p> </body> </html> """.trimIndent(), ) // Create server server = LocalFileWebServer(testFile) // Find a free port port = findFreePort() // Start server in a separate thread val latch = CountDownLatch(1) thread { server.start(port) { stoppable -> serverStoppable = stoppable latch.countDown() } } // Wait for server to start assertTrue(latch.await(5, TimeUnit.SECONDS), "Server did not start within timeout") } @AfterTest fun tearDown() { // Stop server serverStoppable?.stop() // Clean up temp directory tempDir.deleteRecursively() } @Test fun `server serves file`() = runBlocking { // Create HTTP client val client = HttpClient(CIO) try { // Request the file val response = client.get("http://localhost:$port/") // Check response assertEquals(HttpStatusCode.OK, response.status) val responseText = response.bodyAsText() assertTrue(responseText.contains("<title>Test Page</title>")) assertTrue(responseText.contains("<h1>Test Page</h1>")) } finally { client.close() } } @Test fun `websocket reload`() = runBlocking { // Create HTTP client with WebSockets support val client = HttpClient(CIO) { install(WebSockets) } try { // Connect to reload endpoint val messageReceived = CompletableDeferred<String>() // First client to receive reload messages val receiverJob = launch { client.webSocket("ws://localhost:$port/reload") { // Wait for a message val frame = incoming.receive() if (frame is Frame.Text) { messageReceived.complete(frame.readText()) } } } // Wait a bit to ensure connection is established delay(500) // Second client to send reload message val senderJob = launch { client.webSocket("ws://localhost:$port/reload") { // Send a reload message send(Frame.Text("reload")) } } // Wait for message to be received with timeout val receivedMessage = withTimeout(5.seconds) { messageReceived.await() } // Check received message assertEquals("reload", receivedMessage) // Cancel jobs receiverJob.cancel() senderJob.cancel() } finally { client.close() } } @Test fun `concurrent reload requests`() = runBlocking { // Create HTTP client with WebSockets support val client = HttpClient(CIO) { install(WebSockets) } try { // Connect multiple clients and send reload messages concurrently val numClients = 5 val messagesReceived = CompletableDeferred<Int>() var receivedCount = 0 // Start a receiver to count messages val receiverJob = launch { client.webSocket("ws://localhost:$port/reload") { try { repeat(numClients) { val frame = incoming.receive() if (frame is Frame.Text) { receivedCount++ if (receivedCount >= numClients) { messagesReceived.complete(receivedCount) } } } } catch (_: CancellationException) { // Expected when job is cancelled } catch (e: Exception) { println("[DEBUG_LOG] Error in receiver: ${e.message}") } } } // Wait a bit to ensure connection is established delay(500) // Launch multiple senders concurrently val senderJobs = List(numClients) { clientId -> launch { client.webSocket("ws://localhost:$port/reload") { // Send a reload message send(Frame.Text("reload-$clientId")) } } } // Wait for all messages to be received with timeout val count = withTimeout(10.seconds) { messagesReceived.await() } // Check received message count assertEquals(numClients, count) // Cancel jobs receiverJob.cancel() senderJobs.forEach { it.cancel() } } finally { client.close() } } @Test fun `late-connecting client does not receive stale messages`() = runBlocking { val client = HttpClient(CIO) { install(WebSockets) } try { // Client A connects and sends a message. val messageSent = CompletableDeferred<Unit>() val senderJob = launch { client.webSocket("ws://localhost:$port/reload") { send(Frame.Text("reload-1")) messageSent.complete(Unit) // Keep connection open briefly to allow the broadcast to propagate. delay(500) } } // Wait until the message has been broadcast. withTimeout(5.seconds) { messageSent.await() } delay(300) // Client B connects after the message was sent. // It should NOT receive the stale "reload-1" message. val receivedStaleMessage = CompletableDeferred<Boolean>() val lateReceiverJob = launch { client.webSocket("ws://localhost:$port/reload") { try { withTimeout(1.seconds) { incoming.receive() receivedStaleMessage.complete(true) } } catch (_: Exception) { // Timeout means no message was received — expected. receivedStaleMessage.complete(false) } } } val gotStale = withTimeout(5.seconds) { receivedStaleMessage.await() } assertEquals(false, gotStale, "Late-connecting client should not receive stale messages") senderJob.cancel() lateReceiverJob.cancel() } finally { client.close() } } @Test fun `server message content is transmitted correctly`() = runBlocking { val client = HttpClient(CIO) { install(WebSockets) } try { val messageReceived = CompletableDeferred<String>() // Receiver listens for the exact message content. val receiverJob = launch { client.webSocket("ws://localhost:$port/reload") { val frame = incoming.receive() if (frame is Frame.Text) { messageReceived.complete(frame.readText()) } } } delay(500) // Sender transmits the ServerMessage.content value. val senderJob = launch { client.webSocket("ws://localhost:$port/reload") { send(Frame.Text("reload")) } } val received = withTimeout(5.seconds) { messageReceived.await() } assertEquals("reload", received, "Received message should match ServerMessage.content") receiverJob.cancel() senderJob.cancel() } finally { client.close() } } @Test fun `server handles file not found`() = runBlocking { // Create HTTP client val client = HttpClient(CIO) try { // Request a non-existent file val response = client.get("http://localhost:$port/nonexistent.html") // Check response - should be 404 Not Found assertEquals(HttpStatusCode.NotFound, response.status) } finally { client.close() } } // Helper method to find a free port private fun findFreePort(): Int = ServerFreePortScanner( object : Server { override fun start( port: Int, wait: Boolean, onReady: (Stoppable) -> Unit, ) { // Do nothing, just testing if port is available onReady( object : Stoppable { override fun stop() {} }, ) } }, ).run { val portDeferred = CompletableDeferred<Int>() attemptStartUntilPortAvailable(8000) { _, port -> portDeferred.complete(port) } runBlocking { portDeferred.await() } } } ================================================ FILE: quarkdown-server/src/test/kotlin/com/quarkdown/server/ServerFreePortScannerTest.kt ================================================ package com.quarkdown.server import com.quarkdown.server.stop.Stoppable import kotlinx.io.IOException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** * Unit tests for [ServerFreePortScanner]. * * Uses a fake [Server] implementation to control which ports succeed or fail, * without starting a real server. */ class ServerFreePortScannerTest { /** * Creates a [Server] that throws [IOException] for all ports in [failingPorts] * and succeeds (calling [onReady]) for any other port. */ private fun serverFailingOnPorts(failingPorts: Set<Int>): Server = object : Server { override fun start( port: Int, wait: Boolean, onReady: (Stoppable) -> Unit, ) { if (port in failingPorts) { throw IOException("Port $port in use") } onReady( object : Stoppable { override fun stop() {} }, ) } } @Test fun `finds first available port`() { val failingPorts = setOf(8000, 8001, 8002) val scanner = ServerFreePortScanner(serverFailingOnPorts(failingPorts)) var receivedPort = -1 scanner.attemptStartUntilPortAvailable(8000) { _, port -> receivedPort = port } assertEquals(8003, receivedPort) } @Test fun `throws when all ports exhausted`() { // A server that always fails. val alwaysFailing = object : Server { override fun start( port: Int, wait: Boolean, onReady: (Stoppable) -> Unit, ): Unit = throw IOException("Port $port in use") } val scanner = ServerFreePortScanner(alwaysFailing) val exception = assertFailsWith<IOException> { scanner.attemptStartUntilPortAvailable(65534) { _, _ -> } } assertEquals("No available port found in range 65534..65535", exception.message) } @Test fun `succeeds on max port`() { // Fails on 65534, succeeds on 65535. val scanner = ServerFreePortScanner(serverFailingOnPorts(setOf(65534))) var receivedPort = -1 scanner.attemptStartUntilPortAvailable(65534) { _, port -> receivedPort = port } assertEquals(65535, receivedPort) } } ================================================ FILE: quarkdown-server/src/test/resources/test.html ================================================ <!DOCTYPE html> <html lang="en"> <head> <title>Test Page</title> </head> <body> <h1>Test Page</h1> <p>This is a test page for the LocalFileWebServer.</p> </body> </html> ================================================ FILE: quarkdown-stdlib/build.gradle.kts ================================================ plugins { kotlin("jvm") } dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.assertj:assertj-core:3.27.6") testImplementation(testFixtures(project(":quarkdown-core"))) implementation(project(":quarkdown-core")) implementation("se.sawano.java:alphanumeric-comparator:2.0.0") implementation("com.github.doyaaaaaken:kotlin-csv-jvm:1.10.0") implementation("org.kodein.emoji:emoji-kt:2.3.0") dokkaPlugin(project(":quarkdown-quarkdoc")) } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Bibliography.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.createSectionHeading import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyCitation import com.quarkdown.core.ast.quarkdown.bibliography.BibliographyView import com.quarkdown.core.bibliography.style.csl.CslBibliographyStyle import com.quarkdown.core.context.Context import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.wrappedAsValue /** * `Bibliography` stdlib module exporter. * This module handles bibliographies and citations. * @see com.quarkdown.core.bibliography.Bibliography */ val Bibliography: QuarkdownModule = moduleOf( ::bibliography, ::cite, ) /** * The default [CSL](https://citationstyles.org) style used when no explicit style is specified. */ private const val DEFAULT_CSL_STYLE = "ieee" /** * Generates a bibliography from a bibliography file. * * Supported formats include [BibTeX](https://www.bibtex.org) (`.bib`), * CSL JSON, YAML, EndNote, and RIS. * * The bibliography is formatted using a [CSL](https://citationstyles.org) style definition, * powered by [citeproc-java](https://github.com/michel-kraemer/citeproc-java). * This enables support for a curated selection of citation styles from the * [CSL Style Repository](https://github.com/citation-style-language/styles). * * Example: * ```markdown * .bibliography {bibliography.bib} style:{apa} * ``` * * @param path path to the bibliography file, with extension * @param style [CSL](https://citationstyles.org) style identifier (e.g. `apa`, `ieee`, `chicago-author-date`) * from Quarkdown's selection. See the wiki page for a list of supported styles. * @param title title of the bibliography. If unset, the default localized title is used * @param breakPage whether the heading preceding the bibliography triggers an automatic page break. * Enabled by default. * @param headingDepth depth of the heading preceding the bibliography * @param trackHeadingLocation whether the heading preceding the bibliography should be numbered * and have its position tracked in the document hierarchy. * Implicitly enabled when [indexHeading] is enabled. * @param indexHeading whether the heading preceding the bibliography should itself be indexed * in the document's table of contents. * @return an [AstRoot] containing an optional heading and a [BibliographyView] * @see cite to cite bibliography entries * @throws java.io.IOException if the bibliography file cannot be read or parsed * @throws IllegalArgumentException if the specified style does not exist or is invalid * @wiki Bibliography */ fun bibliography( @Injected context: Context, path: String, @LikelyNamed style: String = DEFAULT_CSL_STYLE, @LikelyNamed title: InlineMarkdownContent? = null, @Name("breakpage") breakPage: Boolean = true, @Name("headingdepth") headingDepth: Int = 1, @Name("numberheading") trackHeadingLocation: Boolean = false, @Name("indexheading") indexHeading: Boolean = false, ): NodeValue { val file = file(context, path) val resolvedStyle = CslBibliographyStyle.from(style, file.inputStream(), file.name, context.documentInfo.locale) val heading = Heading.createSectionHeading( title?.children, localizationKey = "bibliography", context, depth = headingDepth, canBreakPage = breakPage, canTrackLocation = trackHeadingLocation, includeInTableOfContents = indexHeading, ) return AstRoot( listOfNotNull( heading, BibliographyView( bibliography = resolvedStyle.bibliography, style = resolvedStyle, ), ), ).wrappedAsValue() } /** * Creates a citation to one or more bibliography entries. * * The result is a label that matches with that of the bibliography entries with the given [key]. * Multiple keys can be specified as a comma-separated list, * producing a single combined label (e.g. `[1], [3]` or `(Einstein, 1905; Hawking, 1988)`). * * Example: * * `bibliography.bib` * * ```bibtex * @article{einstein, * ... * } * * ... * ``` * * Quarkdown: * * ```markdown * Einstein's work .cite {einstein} is fundamental to modern physics. * * These results .cite {einstein, latexcompanion} are well-known. * * .bibliography {bibliography.bib} * ``` * * Result: * ```text * Einstein's work [1] is fundamental to modern physics. * * These results [1], [2] are well-known. * ``` * @param key the key (or comma-separated keys) of the bibliography entries to cite * @return a wrapped [BibliographyCitation] node * @throws IllegalArgumentException if no non-blank citation key is provided * @wiki Bibliography#citations */ fun cite(key: String): NodeValue { val keys = key .split(",") .filter { it.isNotBlank() } .map { it.trim() } require(keys.isNotEmpty()) { "At least one citation key must be specified." } return BibliographyCitation(keys).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Collection.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.PairValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.stdlib.internal.asDouble /** * Index of the first element in a collection. */ internal const val INDEX_STARTS_AT = 1 /** * `Collection` stdlib module exporter. * This module handles iterable collections. */ val Collection: QuarkdownModule = moduleOf( ::collectionGet, ::collectionFirst, ::collectionSecond, ::collectionThird, ::collectionLast, ::collectionSize, ::collectionSumAll, ::collectionAverage, ::collectionDistinct, ::collectionSorted, ::collectionReverse, ::collectionGroup, ::pair, ) /** * @param index index of an element in a collection, starting at 0 * @return the index of the element in Quarkdown (starting at 1) */ private fun quarkdownIndexToKotlin(index: Int) = index - INDEX_STARTS_AT /** * @param index index of the element to get (starting at 0) * @param collection collection to get the element from * @param fallback value to return if the index is out of bounds. If unset, [none] is returned. * @return element at the given index, or [fallback] if the index is out of bounds */ private fun nativeCollectionGet( index: Int, collection: Iterable<OutputValue<*>>, fallback: OutputValue<*> = NOT_FOUND, ): OutputValue<*> = collection.toList().getOrNull(index) ?: fallback /** * @param collection collection to get the element from * @param index index of the element to get **(starting at 1)** * @param fallback value to return if the index is out of bounds. If unset, `false` is returned. * @return element at the given index, or [none] if the index is out of bounds */ @Name("getat") @LikelyChained fun collectionGet( @Name("from") collection: Iterable<OutputValue<*>>, index: Int, @Name("orelse") fallback: DynamicValue = DynamicValue(NOT_FOUND), ) = nativeCollectionGet(quarkdownIndexToKotlin(index), collection, fallback) /** * @param collection collection to get the first element from * @return first element of the collection, or [none] if the collection is empty */ @Name("first") @LikelyChained fun collectionFirst( @Name("from") collection: Iterable<OutputValue<*>>, ) = nativeCollectionGet(0, collection) /** * @param collection collection to get the second element from * @return second element of the collection, or [none] if the collection has less than 2 elements */ @Name("second") @LikelyChained fun collectionSecond( @Name("from") collection: Iterable<OutputValue<*>>, ) = nativeCollectionGet(1, collection) /** * @param collection collection to get the third element from * @return third element of the collection, or [none] if the collection has less than 3 elements */ @Name("third") @LikelyChained fun collectionThird( @Name("from") collection: Iterable<OutputValue<*>>, ) = nativeCollectionGet(2, collection) /** * @param collection collection to get the last element from * @return last element of the collection, or [none] if the collection is empty */ @Name("last") @LikelyChained fun collectionLast( @Name("from") collection: Iterable<OutputValue<*>>, ): OutputValue<*> = collection.toList().lastOrNull() ?: NOT_FOUND /** * @param collection collection to get the size of * @return the non-negative size of the collection */ @Name("size") @LikelyChained fun collectionSize( @Name("of") collection: Iterable<OutputValue<*>>, ): NumberValue = collection.count().wrappedAsValue() /** * @param collection numeric collection to sum * @return the sum of all elements in the collection. If an element is not numeric it is ignored. */ @Name("sumall") @LikelyChained fun collectionSumAll( @Name("from") collection: Iterable<OutputValue<*>>, ): NumberValue = collection .sumOf { it.asDouble() } .wrappedAsValue() /** * @param collection numeric collection to get the average from * @return the average of all elements in the collection. If an element is not numeric it is ignored. */ @Name("average") @LikelyChained fun collectionAverage( @Name("from") collection: Iterable<OutputValue<*>>, ): NumberValue = collection .map { it.asDouble() } .average() .wrappedAsValue() /** * @param collection collection to get the distinct elements from * @return a new collection with the same elements as the original, without duplicates */ @Name("distinct") @LikelyChained fun collectionDistinct( @Name("from") collection: Iterable<OutputValue<*>>, ): IterableValue<OutputValue<*>> = collection.distinct().wrappedAsValue() /** * @param collection collection to sort * @param sorting optional sorting function. If not provided, the collection is sorted by its natural order. * @return a new collection with the same elements as the original, sorted * @throws IllegalArgumentException if the elements, or the properties supplied by [sorting], cannot be compared */ @Suppress("UNCHECKED_CAST") @Name("sorted") @LikelyChained fun collectionSorted( @Name("from") collection: Iterable<OutputValue<*>>, @Name("by") sorting: Lambda? = null, ): IterableValue<OutputValue<*>> { fun toComparable(value: Value<*>): Comparable<Comparable<*>> = value.unwrappedValue.let { requireNotNull(it) as? Comparable<Comparable<*>> ?: throw IllegalArgumentException("Cannot sort collection of unsortable type ${it::class}") } return when { sorting != null -> collection.sortedBy { val selector = sorting.invokeDynamic(it) toComparable(selector) } else -> collection.sortedBy { toComparable(it) } }.wrappedAsValue() } /** * @param collection collection to reverse * @return a new collection with the same elements as the original, in reverse order */ @Name("reversed") @LikelyChained fun collectionReverse( @Name("from") collection: Iterable<OutputValue<*>>, ): IterableValue<OutputValue<*>> = collection.reversed().wrappedAsValue() /** * Groups a collection by their value. * @param collection collection to group * @return a collection of collections, each containing the elements that are equal to each other */ @Name("groupvalues") @LikelyChained fun collectionGroup( @Name("from") collection: Iterable<OutputValue<*>>, ): IterableValue<IterableValue<OutputValue<*>>> = collection .asSequence() .groupBy { it } .mapValues { it.value.toList() } .mapValues { it.value.wrappedAsValue() } .values .let(::GeneralCollectionValue) /** * Creates a new pair. * @param first first element of the pair * @param second second element of the pair * @return a pair of the two elements */ fun pair( first: DynamicValue, second: DynamicValue, ): PairValue<*, *> = PairValue(first to second) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Data.kt ================================================ package com.quarkdown.stdlib import com.github.doyaaaaaken.kotlincsv.dsl.csvReader import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.context.Context import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.data.subList import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.util.normalizeLineSeparators import com.quarkdown.stdlib.internal.AlphanumericComparator import com.quarkdown.stdlib.internal.Ordering import com.quarkdown.stdlib.internal.RootGranularity import com.quarkdown.stdlib.internal.Sorting import com.quarkdown.stdlib.internal.getRootFileSystem import com.quarkdown.stdlib.internal.sortedBy import java.io.File /** * `Data` stdlib module exporter. * This module handles content fetched from external resources. */ val Data: QuarkdownModule = moduleOf( ::read, ::pathToRoot, ::listFiles, ::fileName, ::csv, ) /** * @param path path of the file, relative or absolute (with extension) * @param requireExistance whether the corresponding file must exist * @return a [File] instance of the file located in [path]. * If the path is relative, the location is determined by the working directory of the pipeline. * @throws IllegalArgumentException if the file does not exist and [requireExistance] is `true` */ internal fun file( context: Context, path: String, requireExistance: Boolean = true, ): File { val file = context.fileSystem.resolve(path) if (requireExistance && !file.exists()) { throw IllegalArgumentException("File $file does not exist.") } return file } /** * @param path path of the file (with extension) * @param lineRange range of lines to extract from the file. * If not specified or infinite, the whole file is read * @return a string value of the text extracted from the file * @throws IllegalArgumentException if [lineRange] is out of bounds * @wiki File data */ fun read( @Injected context: Context, path: String, @Name("lines") lineRange: Range = Range.INFINITE, ): StringValue { val file = file(context, path) // If the range is infinite on both ends, the whole file is read. if (lineRange.isInfinite) { return StringValue(file.readText().normalizeLineSeparators().toString()) } // Lines from the file in the given range. val lines = file.readLines() // Check if the range is in bounds. val bounds = Range(1, lines.size) if (lineRange !in bounds) { throw IllegalArgumentException("Invalid range $lineRange in bounds $bounds") } return lines .subList(lineRange) .joinToString("\n") .wrappedAsValue() } /** * Retrieves the relative path to the root of the file system. * The root of the file system is determined by the working directory of either the project or the current subdocument, depending on the specified [granularity]. * * If set to [RootGranularity.PROJECT], the root is the parent directory of the target file being processed by the `quarkdown compile` command. * If set to [RootGranularity.SUBDOCUMENT], the root is the parent directory of the current subdocument file. * * For example, consider the following file tree: * ``` * My-Project/ * ├─ main.qd * ├─ file.txt * ├─ subdocuments/ * ├─ subdocument.qd * ├─ file.txt * ├─ utils/ * ├─ example.qd * ``` * * If `main.qd` invokes `.pathtoroot`, the result is `.` regardless of the granularity, since the working directory is the root of the project. * * Consider the content of `example.qd`: * ``` * .read {.pathtoroot/file.txt} * * .read {.pathtoroot granularity:{subdocument}/file.txt} * ``` * * Now, assume `main.qd` invokes `.include {utils/example.qd}`. * Regardless of the granularity, the `.pathtoroot` calls in `example.qd` return `..`, * so that `My-Project/file.txt` is correctly accessed by `My-Project/utils/example.qd` through the relative path `../file.txt`. * * Assume now that `subdocument.qd` is a subdocument and invokes `.include {../utils/example.qd}`. * - With [RootGranularity.PROJECT], the `.pathtoroot` calls in `example.qd` return `..`, * so that `My-Project/file.txt` is accessed by `My-Project/utils/example.qd` through the relative path `../file.txt`. * - With [RootGranularity.SUBDOCUMENT], the `.pathtoroot` calls in `example.qd` return `../subdocuments`, * so that `My-Project/subdocuments/file.txt` is accessed by `My-Project/utils/example.qd` through the relative path `../subdocuments/file.txt`. * * @param granularity the granularity for determining the root of the file system * @return a string value of the relative path to the root of the file system * @throws IllegalStateException if the relative path cannot be determined */ @Name("pathtoroot") fun pathToRoot( @Injected context: Context, @LikelyNamed granularity: RootGranularity = RootGranularity.PROJECT, ): StringValue { val root: FileSystem = getRootFileSystem(context, granularity) ?: return ".".wrappedAsValue() val path: String = context.fileSystem.relativePathTo(root)?.toString() ?: throw IllegalStateException( """ Unable to determine relative path to file system root. Root: ${root.workingDirectory} Current working directory: ${context.fileSystem.workingDirectory} """.trimIndent(), ) if (path.isEmpty()) { return ".".wrappedAsValue() } return path.wrappedAsValue() } /** * Criterion to sort files by. * @param sort lambda that sorts a sequence of files * @see listFiles */ enum class FileSorting( override val sort: (Sequence<File>, Ordering) -> Sequence<File>, ) : Sorting<File> { /** No sorting is applied. */ NONE({ files, _ -> files }), /** Files are sorted by name. */ NAME({ files, ordering -> files.sortedBy(ordering, AlphanumericComparator) { it.name.lowercase() } }), /** Files are sorted by last modified date. */ LAST_MODIFIED({ files, ordering -> files.sortedBy(ordering) { it.lastModified() } }), } /** * Lists the files located in a directory. * @param path path of the directory to list files from * @param listDirectories whether to include directories in the listing * @param fullPath whether to return the absolute path of each file, rather than just the file name * @param sortBy criterion to sort the files by * @param order order to sort the files in * @return an unordered collection of string values, each representing a file located in the directory, with extension * @throws IllegalArgumentException if the directory does not exist or if the path is not a directory * @see fileName to exclude the extension from file names * @wiki File data */ @Name("listfiles") fun listFiles( @Injected context: Context, path: String, @Name("directories") listDirectories: Boolean = true, @Name("fullpath") fullPath: Boolean = true, @Name("sortby") sortBy: FileSorting = FileSorting.NONE, @LikelyNamed order: Ordering = Ordering.ASCENDING, ): IterableValue<StringValue> { val directory = file(context, path) if (!directory.exists()) { throw IllegalArgumentException("Directory $directory does not exist.") } if (!directory.isDirectory) { throw IllegalArgumentException("Path $directory is not a directory.") } val files = directory .listFiles() ?.asSequence() ?.filter { listDirectories || it.isFile } ?.let { sortBy.sort(it, order) } ?.map { if (fullPath) it.absolutePath else it.name } ?.map(::StringValue) ?: emptySequence() return when { sortBy == FileSorting.NONE -> UnorderedCollectionValue(files.toSet()) else -> OrderedCollectionValue(files.toList()) } } /** * Retrieves the name of a file located in [path]. * @param path path of the file (with extension) * @param includeExtension whether to include the file extension in the name * @return the name of the file located in [path] * @throws IllegalArgumentException if the file does not exist * @wiki File data */ @Name("filename") fun fileName( @Injected context: Context, path: String, @Name("extension") includeExtension: Boolean = true, ): StringValue { val file = file(context, path) val name = if (includeExtension) file.name else file.nameWithoutExtension return StringValue(name) } /** * Strategies to parse CSV cell content. * @param transform function that transforms the cell content string into parsed content */ enum class CsvParsingMode( val transform: (String, Context) -> InlineContent, ) { /** Cell content is treated as plain text. */ PLAIN({ text, _ -> buildInline { text(text) } }), /** Cell content is treated as inline Quarkdown. */ MARKDOWN({ text, context -> ValueFactory.inlineMarkdown(text, context).unwrappedValue.children }), } /** * Loads a CSV file and returns its content as a display-ready table. * @param path path of the CSV file (with extension) to show * @param mode mode to handle the content of each cell and header (plain or Markdown) * @param caption optional caption of the table. If set, the table will be numbered according to the current [numbering] format * @param referenceId optional ID for cross-referencing via [reference] * @return a table whose content is loaded from the file located in [path] * @wiki File data */ fun csv( @Injected context: Context, path: String, @LikelyNamed mode: CsvParsingMode = CsvParsingMode.PLAIN, @LikelyNamed caption: String? = null, @Name("ref") referenceId: String? = null, ): NodeValue { val file = file(context, path) val columns = mutableListOf<Table.MutableColumn>() // CSV is read row-by-row, while the Table is built by columns. csvReader().open(file) { readAllWithHeaderAsSequence() .forEach { row -> row.entries.forEachIndexed { index, (header, content) -> val cell = mode.transform(content.trim(), context).let(Table::Cell) if (index < columns.size) { // Adding cell to existing column. columns[index].cells += cell } else { // Pushing new column. val headerCell = mode.transform(header.trim(), context).let(Table::Cell) columns += Table.MutableColumn( alignment = Table.Alignment.NONE, header = headerCell, cells = mutableListOf(cell), ) } } } } return Table( columns = columns.map { it.toColumn() }, caption = caption, referenceId = referenceId, ).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Dictionary.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.OutputValue /** * `Dictionary` stdlib module exporter. * This module handles map-like dictionaries. */ val Dictionary: QuarkdownModule = moduleOf( ::dictionary, ::dictionaryGet, ) /** * Makes the initialization of a dictionary explicit, to avoid ambiguity with collection initialization. * ``` * .var {dict} * .dictionary * - a: * - aa: 1 * - ab: 2 * - b: * - ba: 3 * - bb: 4 * * .foreach {.dict} * It would not iterate key-value pairs properly without the explicit `.dictionary` call. * ``` * @param dictionary dictionary to initialize * @return the dictionary * @wiki Dictionary */ fun dictionary( @LikelyBody dictionary: Map<String, OutputValue<*>>, ): DictionaryValue<*> = DictionaryValue(dictionary.toMutableMap()) /** * Gets a value from a dictionary by its key. * @param key key to get the value of * @param dictionary dictionary to get the value from * @param fallback value to return if the key is not present. If unset, `false` is returned. * @return value corresponding to the given key, or [NOT_FOUND] if the key is not present */ @Name("get") @LikelyChained fun dictionaryGet( key: String, @Name("from") dictionary: Map<String, OutputValue<*>>, @Name("orelse") fallback: DynamicValue = DynamicValue(NOT_FOUND), ): OutputValue<*> = dictionary[key] ?: fallback ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Document.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.base.block.createSectionHeading import com.quarkdown.core.ast.base.block.marker import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.NavigationContainer import com.quarkdown.core.ast.quarkdown.block.toc.TableOfContentsView import com.quarkdown.core.ast.quarkdown.inline.LastHeading import com.quarkdown.core.ast.quarkdown.inline.PageCounter import com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer import com.quarkdown.core.ast.quarkdown.invisible.PageNumberFormatter import com.quarkdown.core.ast.quarkdown.invisible.PageNumberReset import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.document.DocumentAuthor import com.quarkdown.core.document.DocumentInfo import com.quarkdown.core.document.DocumentTheme import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.deepCopy import com.quarkdown.core.document.layout.caption.CaptionPosition import com.quarkdown.core.document.layout.caption.CaptionPositionInfo import com.quarkdown.core.document.layout.caption.merge import com.quarkdown.core.document.layout.font.FontInfo import com.quarkdown.core.document.layout.page.PageFormatInfo import com.quarkdown.core.document.layout.page.PageFormatSelector import com.quarkdown.core.document.layout.page.PageMarginPosition import com.quarkdown.core.document.layout.page.PageOrientation import com.quarkdown.core.document.layout.page.PageSide import com.quarkdown.core.document.layout.page.PageSizeFormat import com.quarkdown.core.document.layout.page.merge import com.quarkdown.core.document.layout.paragraph.ParagraphStyleInfo import com.quarkdown.core.document.layout.paragraph.merge import com.quarkdown.core.document.numbering.DocumentNumbering import com.quarkdown.core.document.numbering.NumberingFormat import com.quarkdown.core.document.numbering.merge import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.document.tex.TexInfo import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.reflect.annotation.NotForDocumentType import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.dictionaryOf import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.misc.color.Color import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.core.pipeline.error.IOPipelineException import com.quarkdown.stdlib.internal.loadFontFamily /** * `Document` stdlib module exporter. * This module handles document information and details. * @see com.quarkdown.core.document.DocumentInfo */ val Document: QuarkdownModule = moduleOf( ::docType, ::docName, ::docDescription, ::docAuthor, ::docAuthors, ::docKeywords, ::docLanguage, ::theme, ::numbering, ::disableNumbering, ::font, ::paragraphStyle, ::captionPosition, ::texMacro, ::pageFormat, ::pageMarginContent, ::footer, ::formatPageNumber, ::resetPageNumber, ::currentPage, ::totalPages, ::lastHeading, ::autoPageBreak, ::disableAutoPageBreak, ::marker, ::navigationContainer, ::tableOfContents, ) /** * If [value] is not `null`, it updates document information (according to [modify]). * Document information is fetched from [this] context via [Context.documentInfo]. * If it's `null`, the needed value (according to [get]) from the current document is returned. * @param value (optional) value to assign to a document info field * @param get function to fetch the needed value from [DocumentInfo] if [value] is `null` * @param modify function to modify [DocumentInfo] if [value] is not `null` (returns the modified copy) * @return the result of [get], wrapped in a [StringValue], if [value] is `null`. [VoidValue] otherwise */ private fun <T> MutableContext.modifyOrEchoDocumentInfo( value: T?, get: DocumentInfo.() -> OutputValue<*>, modify: DocumentInfo.(T) -> DocumentInfo, ): OutputValue<*> { if (value == null) { return get(this.documentInfo) } this.documentInfo = modify(this.documentInfo, value) return VoidValue } /** * If [type] is specified, sets the document type to that value. * The document type affects its final output style, numbering format and several other properties. * * ``` * .doctype {paged} * ``` * * If it's unset, the lowecase name of the current document type is returned. * * ``` * The current document type is .doctype * ``` * * @param type optional type to assign to the document * @return the lowercase name of the current document type if [type] is unset, nothing otherwise * @wiki Document types */ @Name("doctype") fun docType( @Injected context: MutableContext, type: DocumentType? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( type, get = { this.type.name .lowercase() .wrappedAsValue() }, modify = { copy(type = it) }, ) /** * If [name] is specified, sets the document name to that value. * The document name affects the name of the output file. * * ``` * .docname {My document} * ``` * * If it's unset, the current name of the document is returned. * * ``` * The current document name is .docname * ``` * * @param name optional non-blank name to assign to the document * @return the current document name if [name] is unset, nothing otherwise * @throws IllegalArgumentException if [name] is blank * @wiki Document metadata */ @Name("docname") fun docName( @Injected context: MutableContext, name: String? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( name, get = { (this.name ?: "").wrappedAsValue() }, modify = { require(it.isNotBlank()) { "Document name cannot be blank." } copy(name = it) }, ) /** * If [description] is specified, sets the document description to that value. * * In HTML, descriptions are exported to SEO-friendly meta tags. * * ``` * .docdescription {This is a sample document} * ``` * * If it's unset, the current description of the document is returned. * * ``` * The current document description is .docdescription * ``` * * @param description optional description to assign to the document * @return the current document description if [description] is unset, nothing otherwise * @wiki Document metadata */ @Name("docdescription") fun docDescription( @Injected context: MutableContext, description: String? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( description, get = { (this.description ?: "").wrappedAsValue() }, modify = { copy(description = it) }, ) /** * If [author] is specified, sets the document author to that value. * This is a shortcut for [docAuthors] when there's only one author without additional information. * * ``` * .docauthor {John Doe} * ``` * * If it's unset, the current author of the document is returned. * * If the authors were set via [docAuthors], only the name of the first author is returned. * If you are looking forward to iterating over all authors, use [forEach] over [docAuthors] instead. * * ``` * The current document author is .docauthor * ``` * * @param author optional author name to assign to the document * @return the current document author if [author] is unset, nothing otherwise * @wiki Document metadata */ @Name("docauthor") fun docAuthor( @Injected context: MutableContext, author: String? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( author, get = { (this.authors.firstOrNull()?.name ?: "").wrappedAsValue() }, modify = { copy(authors = authors + DocumentAuthor(name = it)) }, ) /** * If [authors] is specified, sets the document authors to that value. * Compared to [docAuthor], this function allows for multiple authors and additional information. * * ``` * .docauthors * - John Doe * - email: johndoe@email.com * - website: https://github.com/iamgio/quarkdown * - Jane Doe * - email: janedoe@email.com * ``` * * If it's unset, the current authors of the document are returned as a dictionary, * where each key is the author's name, and its value is another dictionary containing the additional information. * * The following example takes advantage of [forEach] with destructuring to iterate over all authors and their information. * * ``` * .foreach {.docauthors} * name info: * .name, .get {email} from:{.info} * ``` * * > Output: * > * > John Doe, johndoe@email.com * > * > Jane Doe, janedoe@email.com * * @param authors optional authors to assign to the document. * Each dictionary entry contains the author's name associated with a nested dictionary of additional information. * @return the current document authors if [authors] is unset, nothing otherwise * @wiki Document metadata */ @Name("docauthors") fun docAuthors( @Injected context: MutableContext, authors: Map<String, DictionaryValue<OutputValue<String>>>? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( authors, get = { // List<(String, Map<String, String>)> -> Map<String, Map<String, String>> dictionaryOf( this.authors.map { it.name to DictionaryValue( it.info.mapValues { (_, value) -> value.wrappedAsValue() }.toMutableMap(), ) }, ) }, modify = { // Map<String, Map<String, String>> -> List<(String, Map<String, String>)> val authors = this.authors + it.map { (name, info) -> DocumentAuthor( name = name, info = info.unwrappedValue.mapValues { (_, value) -> value.unwrappedValue }, ) } copy(authors = authors) }, ) /** * If [keywords] is specified, sets the document keywords to those values. * In HTML, keywords are exported to SEO-friendly meta tags. * * ``` * .dockeywords * - quarkdown * - markdown * - documentation * ``` * * If it's unset, the current keywords of the document are returned as a list. * * ``` * .foreach {.dockeywords} * keyword: * .keyword * ``` * * @param keywords optional collection of keywords to assign to the document * @return the current document keywords if [keywords] is unset, nothing otherwise * @wiki Document metadata */ @Name("dockeywords") fun docKeywords( @Injected context: MutableContext, @LikelyBody keywords: Iterable<DynamicValue>? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( keywords, get = { this.keywords.map(::StringValue).wrappedAsValue() }, modify = { copy(keywords = it.map { value -> value.unwrappedValue.toString() }.toList()) }, ) /** * If [locale] is specified, sets the document language to that value. * The document language affects localization ([localization], [localize]), hyphenation and other locale-specific properties. * * For a list of supported locales for built-in localizations, see [here](https://quarkdown.com/wiki/localization). * * ``` * .doclang {en} * ``` * * or * * ``` * .doclang {English} * ``` * * If it's unset, the current language of the document is returned as its localized name (e.g. `English`, `Italiano`, `Français`). * * ``` * The current document language is .doclang * ``` * * @param locale optional, case-insensitive, * either a locale tag (e.g. `en`, `en-US`, `it`, `fr-CA`) * or an English name of a locale (e.g. `English`, `English (United States)`, `Italian`, `French (Canada)`) * to assign to the document * @return the localized name of the current document language if [locale] is unset, nothing otherwise * @throws IllegalArgumentException if the locale tag is invalid or not found * @wiki Document metadata */ @Name("doclang") fun docLanguage( @Injected context: MutableContext, locale: String? = null, ): OutputValue<*> = context.modifyOrEchoDocumentInfo( locale, get = { (this.locale?.localizedName ?: "").wrappedAsValue() }, modify = { copy( locale = LocaleLoader.SYSTEM.find(it) ?: throw IllegalArgumentException("Locale $it not found"), ) }, ) /** * Sets the document theme. * * The two components of a theme are: * - Color themes, which define the color scheme of a document, including colors for text, backgrounds, and other elements. * - Layout themes, which define the general structural rules of the layout, including margins, spacing, and positioning. * * If any of the components isn't specified, the current one is kept, or the default one is used if not set yet. * * Check out the wiki page for a list of available themes. * * @param color optional color scheme to assign * @param layout layout format to assign * @throws IOPipelineException if any of the theme components isn't resolved * @wiki Themes */ fun theme( @Injected context: MutableContext, color: String? = null, @LikelyNamed layout: String? = null, ): VoidValue { /** * @throws IOPipelineException if [theme] is not a valid theme */ fun checkExistance(theme: String) { object {}.javaClass.getResource("/render/theme/${theme.lowercase()}.css") ?: throw IOPipelineException("Theme $theme not found") } val theme = DocumentTheme( color = color?.lowercase()?.also { checkExistance("color/$it") }, layout = layout?.lowercase()?.also { checkExistance("layout/$it") }, ) // Update global theme. context.documentInfo = context.documentInfo.copy(theme = theme) return VoidValue } /** * Sets the global numbering format across the document. * Numbering is applied to elements that support it, such as headings and figures. * * - If a format is `none`, that kind of numbering is disabled. * * - Otherwise, it accepts a string where each character represents a symbol. * Some characters are reserved for counting: * - `1` for decimal (`1, 2, 3, ...`) * - `a` for lowercase latin alphabet (`a, b, c, ...`) * - `A` for uppercase latin alphabet (`A, B, C, ...`) * - `i` for lowercase roman numerals (`i, ii, iii, ...`) * - `I` for uppercase roman numerals (`I, II, III, ...`) * * Any other character is considered a fixed symbol. * * Sample numbering strings are `1.1.1`, `1.A.a`, `A.A`. * * ```yaml * .numbering * - headings: 1.1 * - figures: 1.a * ``` * * If this function is *not* called, the default numbering format is picked depending on the document type. * * @param merge if true, merges the given formats with the current ones (including defaults), * so that unspecified formats are kept unchanged. * If false, completely overrides the current formats, so that unspecified formats are disabled. * @param formats dictionary of numbering formats for different element types. * Built-in keys are: * - `headings`, used for headings (titles) and [tableOfContents] entries; * - `figures`, used for captioned images; * - `tables`, used for captioned tables; * - `equations`, used for math blocks; * - `code`, used for code blocks; * - `footnotes`, used for footnotes and references to them. * Any other key can be addressed by custom elements (see [numbered]). * @wiki Numbering */ fun numbering( @Injected context: MutableContext, @LikelyNamed merge: Boolean = true, @LikelyBody formats: Map<String, Value<String>>, ): VoidValue { fun parse(format: Value<String>): NumberingFormat = when (val unwrapped = format.unwrappedValue) { // Disable numbering. Setting to null would instead trigger the default one. "none" -> NumberingFormat(symbols = emptyList()) // Parse the format string. else -> NumberingFormat.fromString(unwrapped) } val numbering = DocumentNumbering( headings = formats["headings"]?.let(::parse), figures = formats["figures"]?.let(::parse), tables = formats["tables"]?.let(::parse), math = formats["equations"]?.let(::parse), codeBlocks = formats["code"]?.let(::parse), footnotes = formats["footnotes"]?.let(::parse), extra = formats.map { (key, value) -> key to parse(value) }.toMap(), ) context.documentInfo = context.documentInfo.copy( numbering = if (merge) { numbering.merge(context.documentInfo.numberingOrDefault) } else { numbering }, ) return VoidValue } /** * Disables numbering across the document, in case a default numbering is set by either [numbering] or the document type default. * @see numbering * @wiki Numbering */ @Name("nonumbering") fun disableNumbering( @Injected context: MutableContext, ) = numbering(context, merge = false, formats = emptyMap()) /** * Updates the global font configuration of the document. * * Font families can be loaded from any of the following sources: * - From file (e.g. `path/to/font.ttf`) * - From URL (e.g. `https://example.com/font.ttf`) * - From system fonts (e.g. `Arial`, `Times New Roman`) * - From Google Fonts (e.g. `GoogleFonts:Roboto`). * * Local and remote font resources are processed by the [media storage](https://quarkdown.com/wiki/media-storage). * This means, for instance, HTML output will carry local fonts into the output directory for increased portability. * * This function can be called multiple times to add multiple font configurations. * Latter configurations have higher priority. This can be useful to specify fallback fonts for different glyphs. * The following example applies the `Inter` font for Latin characters, and the `Ma Shan Zheng` font for Chinese characters. * This happens because `Inter` does not support Chinese glyphs, so the renderer falls back to the next configuration. * * ``` * .font {GoogleFonts:Ma Shan Zheng} * .font {GoogleFonts:Inter} * ``` * * The [size] parameter does not stack instead, and only the last specified size is used among all configurations. * * @param main main font family of regular content: paragraphs, lists, blockquotes, tables and other textual elements * @param heading font family of headings. * If unset, [main] is applied to headings too * only if the layout [theme] does not enforce a specific heading font * @param code font family of code blocks and code spans * @param size main font size of the text on each page. Other elements, such as headings, will scale accordingly * @wiki Font configuration */ fun font( @Injected context: MutableContext, main: String? = null, @LikelyNamed heading: String? = null, @LikelyNamed code: String? = null, @LikelyNamed size: Size? = null, ): VoidValue { fun fontFamily(name: String?): FontFamily? = name?.let { loadFontFamily(it, context) } val fontInfo = FontInfo( mainFamily = fontFamily(main), headingFamily = fontFamily(heading), codeFamily = fontFamily(code), size = size, ) // Update global font info. context.documentInfo = context.documentInfo.deepCopy(layoutFonts = context.documentInfo.layout.fonts + fontInfo) return VoidValue } /** * Sets the global style of paragraphs in the document. * If a value is unset, the default value supplied by the underlying renderer is used. * * The default values may also be affected by the current document locale, set via [docLanguage]. * For instance, the Chinese `zh` locale prefers a 2em indentation and no vertical spacing by default. * * @param lineHeight height of each line, multiplied by the font size * @param letterSpacing whitespace between letters, multiplied by the font size * @param spacing whitespace between paragraphs, multiplied by the font size. * This also minorly affects whitespace around lists and between list items * @param indent whitespace at the start of each paragraph, multiplied by the font size. * LaTeX's policy is used: indenting the first line of paragraphs, except the first one and aligned ones * @wiki Paragraph style */ @Name("paragraphstyle") fun paragraphStyle( @Injected context: MutableContext, @Name("lineheight") lineHeight: Number? = null, @Name("letterspacing") letterSpacing: Number? = null, @LikelyNamed spacing: Number? = null, @LikelyNamed indent: Number? = null, ): VoidValue { val currentStyle = context.documentInfo.layout.paragraphStyle val style = ParagraphStyleInfo( lineHeight = lineHeight?.toDouble(), letterSpacing = letterSpacing?.toDouble(), spacing = spacing?.toDouble(), indent = indent?.toDouble(), ) // Update global paragraph style. context.documentInfo = context.documentInfo.deepCopy(layoutParagraphStyle = style.merge(currentStyle)) return VoidValue } /** * Sets the position of captions, relative to the content they describe. * @param default the default position for all captions. Defaults to bottom * @param figures caption position for figures. If set, overrides [default] for figures * @param tables caption position for tables. If set, overrides [default] for tables * @param codeBlocks caption position for code blocks. If set, overrides [default] for code blocks * @wiki Caption position */ @Name("captionposition") fun captionPosition( @Injected context: MutableContext, @LikelyNamed default: CaptionPosition? = null, @LikelyNamed figures: CaptionPosition? = null, @LikelyNamed tables: CaptionPosition? = null, @Name("code") codeBlocks: CaptionPosition? = null, ): VoidValue { val currentPosition = context.documentInfo.layout.captionPosition val position = CaptionPositionInfo( default = default ?: currentPosition.default, figures = figures, tables = tables, codeBlocks = codeBlocks, ) // Update global caption position. context.documentInfo = context.documentInfo.deepCopy(layoutCaptionPosition = position.merge(currentPosition)) return VoidValue } /** * Creates a new global TeX macro that can be accessed within math blocks. * * ``` * .texmacro {\R} * \mathbb{R} * ``` * * @param name name of the macro * @param macro TeX code * @wiki TeX macros */ @Name("texmacro") fun texMacro( @Injected context: MutableContext, name: String, @LikelyBody macro: String, ) = VoidValue.also { val texInfo: TexInfo = context.documentInfo.tex.copy( macros = context.documentInfo.tex.macros + (name to macro), ) context.documentInfo = context.documentInfo.copy(tex = texInfo) } /** * Sets the page layout format of the document. * If a value is unset, the default value supplied by the underlying renderer is used. * * - In case of `paged` documents, this function defines the properties of each page. * - In case of `slides` documents, this function defines the properties of each slide. * - In case of `plain` documents, this function defines some properties of the whole document, seeing it as just one page. * Not all effects of this function are supported in plain documents. * * This function can be called multiple times. Each call appends a new format layer, and later layers * take priority over earlier ones. When [side] is specified, the format is scoped only to specific pages of `paged` documents, * enabling distinct formatting per side (e.g. mirrored margins): * * ``` * .pageformat size:{A4} * .pageformat side:{left} margin:{2cm 3cm 2cm 1cm} * .pageformat side:{right} margin:{2cm 1cm 2cm 3cm} * ``` * * If both [format] and [width] or [height] are set, the latter overrides the former. * If both [format] and [width] or [height] are unset, the default value is used. * * If any of [borderTop], [borderRight], [borderBottom], [borderLeft] or [borderColor] is set, * the border will be applied around the content area of each page. * If only [borderColor] is set, the border will be applied with a default width to each side. * Border is not supported in plain documents. * * @param side the page side this format applies to: `left` (verso) or `right` (recto). * If unset, the format applies to all pages. Combinable with [range]. * Only supported in `paged` documents * @param range 1-based inclusive range of page indices to restrict this format to (e.g. `2..5`). * If unset, the format applies to all pages. Combinable with [side]. * Only supported in `paged` documents * @param format standard size format of each page (overridden by [width] and [height]) * @param orientation orientation of each page. * If not specified, the preferred orientation of the document type is used. * Does not take effect if [format] is not specified. * @param width width of each page * @param height height of each page * @param margin blank space around the content of each page. Not supported in slides documents * @param borderTop border width of the top content area of each page * @param borderRight border width of the right content area of each page * @param borderBottom border width of the bottom content area of each page * @param borderLeft border width of the left content area of each page * @param borderColor color of the border around the content area of each page * @param columns positive number of columns on each page. * If set and greater than 1, the layout becomes multi-column. If < 1, the value is discarded * @param alignment text alignment of the content on each page * @throws IllegalArgumentException if [range] is open-ended (has no finite end) * @wiki Page format */ @Name("pageformat") fun pageFormat( @Injected context: MutableContext, @LikelyNamed side: PageSide? = null, @Name("pages") range: Range? = null, @Name("size") format: PageSizeFormat? = null, @LikelyNamed orientation: PageOrientation = context.documentInfo.type.preferredOrientation, @LikelyNamed width: Size? = null, @LikelyNamed height: Size? = null, @LikelyNamed margin: Sizes? = null, @Name("bordertop") borderTop: Size? = null, @Name("borderright") borderRight: Size? = null, @Name("borderbottom") borderBottom: Size? = null, @Name("borderleft") borderLeft: Size? = null, @Name("bordercolor") borderColor: Color? = null, @LikelyNamed columns: Int? = null, @LikelyNamed alignment: Container.TextAlignment? = null, ): VoidValue { require(range == null || range.end != null) { "Page range must have a finite end." } // If, for instance, the document is landscape and the given format is portrait, // the format is converted to landscape. val formatBounds = format?.getBounds(orientation) // Whether at least one border property is set. val hasBorder = borderTop != null || borderRight != null || borderBottom != null || borderLeft != null val format = PageFormatInfo( selector = PageFormatSelector( side = side, range = range, ).takeUnless { it.isGlobal }, pageWidth = width ?: formatBounds?.width, pageHeight = height ?: formatBounds?.height, margin = margin, columnCount = columns?.takeIf { it > 0 }, alignment = alignment, contentBorderWidth = Sizes( top = borderTop ?: Size.ZERO, right = borderRight ?: Size.ZERO, bottom = borderBottom ?: Size.ZERO, left = borderLeft ?: Size.ZERO, ).takeIf { hasBorder }, contentBorderColor = borderColor, ) // Update global page format. context.documentInfo = context.documentInfo.deepCopy(layoutPageFormats = context.documentInfo.layout.pageFormats + format) return VoidValue } /** * Displays content on each page of a document. * * - In case of `paged` documents, the content is displayed in a dedicated area on each page. * - In case of `slides` documents, the content is displayed on each slide, not in a dedicated area. * - In case of `plain` documents, the content is displayed in a fixed position (not affected by scrolling). * * @param position position of the content within the page * @param content content to be displayed on each page * @return a [PageMarginContentInitializer] node * @wiki Page margin content */ @Name("pagemargin") fun pageMarginContent( position: PageMarginPosition, @LikelyBody content: MarkdownContent, ): NodeValue = PageMarginContentInitializer( content.children, position, ).wrappedAsValue() /** * Displays content on the bottom center of each page of a document. * * This is a shortcut for [pageMarginContent] with `bottomcenter` as its position. * Some themes may style footers differently than other page margin content. * * @param content content to be displayed on each page as a footer * @return a [PageMarginContentInitializer] node with its position set to bottom center * @see pageMarginContent * @wiki Page margin content */ fun footer( @LikelyBody content: MarkdownContent, ): NodeValue = pageMarginContent( PageMarginPosition.BOTTOM_CENTER, content, ) /** * Displays the index (starting from 1) of the page this element lies in. * * In case the current document type does not support page counting (e.g. `plain` documents), `-` is displayed instead. * * @return a new [PageCounter] node * @wiki Page counter */ @Name("currentpage") fun currentPage() = PageCounter(PageCounter.Target.CURRENT).wrappedAsValue() /** * Displays the total amount of pages in the document. * * In case the current document type does not support page counting (e.g. `plain` documents), `-` is displayed instead. * * @return a new [PageCounter] node * @wiki Page counter */ @Name("totalpages") fun totalPages() = PageCounter(PageCounter.Target.TOTAL).wrappedAsValue() /** * Sets a new page number format from the page where this function appears. * * The format string accepts the same syntax as the one in [numbering], but it only affects page numbers from the current page and onwards. * * ``` * .formatpagenumber {i} * ``` * * @param format page number format to apply from the page where this function appears */ @Name("formatpagenumber") fun formatPageNumber(format: String): NodeValue = PageNumberFormatter(format).wrappedAsValue() /** * Resets the logical page number counter. * * The page that contains this command is assigned [startFrom] as its displayed number. * This affects features that read page numbers, such as [currentPage]. * * ``` * .resetpagenumber start:{5} * ``` * * @param startFrom page number to assign to the page where this function appears */ @Name("resetpagenumber") fun resetPageNumber( @Name("start") startFrom: Int = 1, ): NodeValue = PageNumberReset(startFrom).wrappedAsValue() /** * Displays the last heading, of the given [depth], encountered in the current page. * * ```markdown * # The heading * * .lastheading depth:{1} <!-- Displays "The heading" --> * ``` * * If the current page does not contain any, the last heading of the previous page is used instead, continuing backwards until a heading is found. * If, ultimately, no such heading is found in the whole document, nothing is displayed. * * ```markdown * <!-- Page 1 --> * * # Heading 1 * * ## Heading 2 * * <!-- Page 2 --> * * .lastheading depth:{2} <!-- Displays "Heading 2" --> * ``` * * This can be particularly useful in combination with [pageMarginContent], to show the current section of the document: * * ``` * .pagemargin {bottomcenter} * .lastheading depth:{1} * ``` * * Note that encountering a heading of lesser depth than [depth] resets the current last heading. * For instance: * * ```markdown * <!-- Page 1 --> * * # Heading 1 * * ## Heading 2 * * <!-- Page 2 --> * * # Heading 3 * * .lastheading depth:{2} <!-- Empty --> * ``` * * @param depth the depth of the last [Heading] to match (1-6) * @return a new [LastHeading] node * @see pageMarginContent * @wiki Persistent headings */ @NotForDocumentType(DocumentType.PLAIN) @Name("lastheading") fun lastHeading(depth: Int) = LastHeading(depth).wrappedAsValue() /** * Sets a new automatic page break threshold when a heading is found: * if a heading's depth value (the amount of leading `#`s) is equal to or less than [maxDepth], * a page break is forced before the heading. * * If this function is *not* called, automatic page breaks are set depending on the document type. * For instance, `paged` documents have automatic page breaks enabled for headings of depth `1` by default. * * Any page break can be disabled via [disableAutoPageBreak] (or by setting [maxDepth] to `0`). * * @param maxDepth heading depth to force page breaks for (positive only). * @throws IllegalArgumentException if [maxDepth] is a negative value * @see disableAutoPageBreak * @wiki Page break */ @Name("autopagebreak") fun autoPageBreak( @Injected context: MutableContext, @Name("maxdepth") maxDepth: Int, ): VoidValue { if (maxDepth < 0) { throw IllegalArgumentException("Heading depth cannot be negative.") } context.options.autoPageBreakHeadingMaxDepth = maxDepth return VoidValue } /** * Disables automatic page breaks when a heading is found. * @see autoPageBreak * @wiki Page break */ @Name("noautopagebreak") fun disableAutoPageBreak( @Injected context: MutableContext, ) = autoPageBreak(context, 0) /** * Creates an invisible marker, that points to a specific location in the document, * and can be referenced by other elements as would happen with a regular heading. * * It can be particularly useful when using a table of contents. * * @param name name of the marker * @return a [Heading] marker node * @see tableOfContents * @wiki Table of contents */ fun marker(name: InlineMarkdownContent) = Heading.marker(name.children).wrappedAsValue() /** * Creates a navigation container, which marks its content as a navigable section. * * This doesn't affect the layout of the document by itself, but rather brings semantic meaning that * can be used by themes and renderers to provide additional navigation features, styling, behaviors and accessibility. * * For example, this can be useful in `docs` documents to mark sidebar navigation sections, * in combination with [pageMarginContent], in order to list links to subdocuments: * * ``` * .pagemargin {lefttop} * .navigation role:{pagelist} * - [Page 1](page-1.qd) * - [Page 2](page-2.qd) * ``` * * @param role role of the navigation container * @param content content of the container * @return a [NavigationContainer] node */ @Name("navigation") fun navigationContainer( role: NavigationContainer.Role? = null, @LikelyBody content: MarkdownContent, ): NodeValue = NavigationContainer( role, content.children, ).wrappedAsValue() /** * Generates a table of contents, based on the headings in the document, * organized in a hierarchical structure defined by each heading's depth. * * @param title title of the table of contents. If unset, the default localized title is used. If blank, no title is displayed. * @param maxDepth maximum depth of the table of contents. * Only headings with a depth (number of leading `#`s) equal to or less than this value are included. * @param breakPage whether the heading preceding the table of contents triggers an automatic page break. * Enabled by default. * @param headingDepth depth of the heading preceding the table of contents. * If unset, the depth is determined by the document type. * @param trackHeadingLocation whether the heading preceding the table of contents should be numbered * and have its position tracked in the document hierarchy. * Implicitly enabled when [indexHeading] is enabled. * @param indexHeading whether the heading preceding the table of contents should itself be indexed * in the document's table of contents. * @param focusedItem if set, adds focus to the item of the table of contents with the same text content as this argument. * Inline style (strong, emphasis, etc.) is ignored when comparing the text content. * When at least one item is focused, non-focused items are visually de-emphasized. * @return an [AstRoot] containing an optional heading and a [TableOfContentsView] * @wiki Table of contents */ @Name("tableofcontents") fun tableOfContents( @Injected context: Context, @LikelyNamed title: InlineMarkdownContent? = null, @Name("maxdepth") maxDepth: Int = 3, @Name("breakpage") breakPage: Boolean = true, @Name("headingdepth") headingDepth: Int? = null, @Name("numberheading") trackHeadingLocation: Boolean = false, @Name("indexheading") indexHeading: Boolean = false, @Name("focus") focusedItem: InlineMarkdownContent? = null, ): NodeValue { val isDocs = context.documentInfo.type == DocumentType.DOCS return AstRoot( listOfNotNull( Heading.createSectionHeading( title?.children, localizationKey = if (isDocs) "tableofcontents/docs" else "tableofcontents", context, depth = headingDepth ?: if (isDocs) 3 else 1, customId = "table-of-contents", canBreakPage = breakPage, canTrackLocation = trackHeadingLocation, includeInTableOfContents = indexHeading, ), TableOfContentsView( maxDepth, focusedItem?.children, ), ), ).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Ecosystem.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.base.inline.SubdocumentLink import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.ScopeContext import com.quarkdown.core.context.SharedContext import com.quarkdown.core.context.SubdocumentContext import com.quarkdown.core.context.file.FileSystem import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.stdlib.internal.asString import java.io.Reader /** * `Ecosystem` stdlib module exporter. * This module handles interaction between Quarkdown sources. */ val Ecosystem: QuarkdownModule = moduleOf( ::include, ::includeAll, ::subdocument, ) /** * Includes the parsed content of the given raw Quarkdown [code], read by [reader], in the current document. * The context of the main file is shared, allowing for sharing of variables, functions and other declarations. * @param context main context to share * @param reader reader of the raw Quarkdown source to include * @return the content of the file as a node */ internal fun includeResource( context: Context, reader: Reader, ): NodeValue { val code = reader.readText() // Evaluate the Quarkdown source. // This automatically converts the source into a value (e.g. a node, a string, a number, etc.) // and fills the current context with new declarations (e.g. variables, functions, link definitions, etc.) return ValueFactory.blockMarkdown(code, context).asNodeValue() } /** * Relationship between the main context and the included file's context via [include]. * See [include] for details about each mode. */ enum class ContextSandbox { /** @see SharedContext */ SHARE, /** @see ScopeContext */ SCOPE, /** @see SubdocumentContext */ SUBDOCUMENT, } /** * This function has two behaviors: * - Reads a Quarkdown file and includes its parsed content in the current document, * using the specified [sandbox] strategy to determine what information is shared between the main context and the included file's context. * - Loads a library into the current context and includes its parsed content in the current document. * Loadable libraries are fetched from the library folder (`--libs` CLI option). * [sandbox] is ignored in this case. * * The context of the included file is always inherited from the main file, with an updated working directory that matches the included file's location. * [sandbox] defines, instead, what information is shared back to the main file's context, and how. The following modes are listed in ascending order of isolation: * * - `share` (default): exchanges information bi-directionally. Changes made in the included file's context are reflected in the main file's context, and vice versa, * allowing for full sharing of variables, functions and other declarations. * This is represented by [SharedContext]. * * - `scope`: like `share`, but the included file's context does not share new declarations (functions and variables) back to the main file's context. * This is the behavior used within lambda blocks, such as [forEach], and is represented by [ScopeContext]. * * - `subdocument`: no information is shared back to the main file's context, only inherited from it. This also applies to the document info (metadata, title, etc.), * This is the behavior used for subdocuments, and is represented by [SubdocumentContext]. * * @param path either a path (relative or absolute with extension) to the file to include, or the name of a loadable library * @param sandbox relationship between the main context and the included file's context * * @return the content of the file as a node if a file is included, or nothing if a library is loaded * @throws IllegalArgumentException if the loaded Quarkdown source cannot be evaluated * @wiki Including other Quarkdown files */ fun include( @Injected context: MutableContext, path: String, @LikelyNamed sandbox: ContextSandbox = ContextSandbox.SHARE, ): OutputValue<*> { // Load library by name if it exists. context.loadLibrary(path)?.let { (_, value) -> return value ?: VoidValue } // File lookup val file = file(context, path) // Context initialization with updated working directory. val newFileSystem: FileSystem = context.fileSystem.branch(workingDirectory = file.parentFile) val newContext: Context = when (sandbox) { ContextSandbox.SHARE -> SharedContext(context, newFileSystem) ContextSandbox.SCOPE -> ScopeContext(context, newFileSystem) ContextSandbox.SUBDOCUMENT -> SubdocumentContext(context, context.subdocument, newFileSystem) } return includeResource(newContext, file.bufferedReader()) } /** * Performs a bulk include of the given paths via [include] (with the default `share` sandbox). * @param paths paths to the files or library names to include * @return a collection containing the output of the included files * @throws IllegalArgumentException if any of the loaded sources cannot be evaluated * @see include for information about file inclusion * @wiki Including other Quarkdown files */ @Name("includeall") fun includeAll( @Injected context: MutableContext, @LikelyBody paths: Iterable<Value<*>>, ): IterableValue<OutputValue<*>> = paths .map { include(context, it.asString()) } .let(::GeneralCollectionValue) /** * Creates a link to a subdocument located at the given [path]. * * This is an alias to the link syntax, `[Label](path)`, with more freedom: * - Function calls are supported, whereas the link syntax only supports static paths. * - The link syntax recognizes subdocuments only by their file extension (`.qd` or `.md`). * * @param path path to the subdocument * @param label optional label for the link. * If not provided, this function will just add the subdocument to the document graph, without displaying a link. * @param anchor optional anchor to a specific section within the subdocument * @return a [SubdocumentLink] node, which may be hidden if [label] is not provided * @wiki Subdocuments */ fun subdocument( path: String, label: InlineMarkdownContent? = null, anchor: String? = null, ): NodeValue = SubdocumentLink( Link( label = label?.children ?: emptyList(), url = path, title = null, ), anchor = anchor, ).wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Emoji.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.wrappedAsValue import org.kodein.emoji.Emoji import org.kodein.emoji.EmojiTemplateCatalog import org.kodein.emoji.list /** * `Emoji` stdlib module exporter. * This module handles emojis. */ val Emoji: QuarkdownModule = moduleOf( ::emoji, ::allEmojis, ) private val allEmojis by lazy { Emoji.list() } private val emojiCatalog by lazy { EmojiTemplateCatalog(allEmojis) } /** * Inserts an emoji by its shortcode, e.g., `smile`. * * An emoji can be described with: * * - A simple short-code: `.emoji {wink}` produces 😉 * - A short-code with one skin tone: `.emoji {waving-hand~medium-dark}` produces 👋🏾 * - A short-code with two skin tones: `.emoji {people-holding-hands~medium-light,medium-dark}` produces 🧑🏼‍🤝‍🧑🏾 * * A complete list of shortcodes can be found at [quarkdown.com/docs/emoji-list](https://quarkdown.com/docs/emoji-list). * * Note: the first call to this function initializes the emoji catalog, which may take a moment. * Subsequent calls will be faster. * * @param shortcode the shortcode of the emoji to insert (without colons) * @return the emoji as a string, or the shortcode as plain text, surrounded by colons, if not found * @wiki Emojis */ fun emoji(shortcode: String) = emojiCatalog.replaceShortcodes(":$shortcode:").wrappedAsValue() /** * Provides a dictionary of all available emojis, mapping the emoji character to its shortest shortcode. * ``` * .foreach {.allemojis} * emoji shortcode: * The emoji .emoji has the shortcode .shortcode * ``` * * > Did you know? * > [quarkdown.com/docs/emoji-list](https://quarkdown.com/docs/emoji-list) is generated from this function! * * @return a dictionary where keys are emoji characters and values are their shortest shortcodes * @wiki Emojis */ @Name("allemojis") fun allEmojis(): DictionaryValue<StringValue> = allEmojis .associate { it.details.string to (it.details.aliases.minByOrNull { alias -> alias.length } ?: "").wrappedAsValue() } .toMutableMap() .let(::DictionaryValue) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Flow.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.base.block.BlankNode import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.ScopeContext import com.quarkdown.core.function.FunctionParameter import com.quarkdown.core.function.SimpleFunction import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.GeneralCollectionValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.data.LambdaParameter import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue /** * `Flow` stdlib module exporter. * This module handles the control flow and other statements. */ val Flow: QuarkdownModule = moduleOf( ::`if`, ::ifNot, ::forEach, ::repeat, ::function, ::variable, ::let, ::node, ) /** * Performs a conditional evaluation of content, including the evaluation of body only if the condition is met. * The expression is not evaluated if the condition is false. * * Conditional statements return the evaluation of the body if the condition is met, nothing otherwise, * and can be used for different purposes, both for layout and for logic. * * ``` * # Shopping list * * .var {needapples} {yes} * * .if {.needapples} * I need apples. * ``` * * ``` * .row * A * * .if {yes} * B * * C * ``` * * ``` * .function {safedivide} * numerator denominator: * .if {.denominator::equals {0}} * 0 * .ifnot {.denominator::equals {0}} * .numerator::divide by:{.denominator} * ``` * * ``` * .function {greet} * name?: * .if {.name} * Hi, .name! * ``` * * @param condition whether the content should be evaluated. If `none`, it is treated as `false` * @param body content to evaluate if [condition] is verified. Accepts 0 parameters. * @return the evaluation of [body] if [condition] is `true`, nothing otherwise * @wiki Conditional statements */ @Name("if") @Suppress("ktlint:standard:function-naming") fun `if`( condition: Boolean?, @LikelyBody body: Lambda, ): OutputValue<*> = when (condition) { true -> body.invokeDynamic() else -> VoidValue } /** * Shorthand for `.if {.condition::not}`. * * @param condition whether the content should *not* be evaluated. If `none`, it is treated as `false`, so the content will be evaluated * @param body content to evaluate if [condition] is *not* verified. Accepts 0 parameters. * @return [body] if [condition] is false, nothing otherwise * @wiki Conditional statements */ @Name("ifnot") fun ifNot( condition: Boolean?, @LikelyBody body: Lambda, ): OutputValue<*> = `if`(condition?.not() ?: true, body) /** * Repeats content for each element of an iterable collection. * The current element can be accessed via the lambda argument, which may be either explicit or implicit. * * ``` * .var {collection} * - A * - B * - C * * .foreach {.collection} * element: * The current element is **.element** * ``` * * In implicit form: * * ``` * .foreach {.collection} * The current element is **.1** * ``` * * In case the iterable is destructurable (e.g. a [dictionary] or [pair]) and the lambda body has more than 1 explicit parameter, * the value is destructured into components. * * ``` * .var {x} * .dictionary * - a: 1 * - b: 2 * - c: 3 * * .foreach {.x} * key value: * **.key** has value **.value** * ``` * * The output is a collection containing the output of each iteration (mapping), so, if used as a value, * this function has a meaning similar to `map` in many languages. * * ``` * .var {collection} * - A * - B * - C * * .var {mappedcollection} * .foreach {.collection} * item: * .item::lowercase * * .mappedcollection::first * ``` * * > Output: `a` * * Note that, like any lambda, its content can be inlined by means of the `@lambda` annotation. * The previous snippet can be simplified as follows: * * ``` * .foreach {.collection} {@lambda item: .item::lowercase}::first * ``` * * @param iterable collection to iterate * @param body the output of each iteration. Accepts 1 parameter (the current element). * @return a collection that contains the output of each iteration * @wiki Loops */ @Name("foreach") fun forEach( iterable: Iterable<Value<*>>, @LikelyBody body: Lambda, ): IterableValue<OutputValue<*>> { val values = iterable.map { value -> body.invokeDynamic(value) } return GeneralCollectionValue(values) } /** * Repeats content `N` times. This is shorthand for `.foreach {..N}`. * * The current index (starting from 1) can be accessed via the lambda argument. * * ``` * .repeat {5} * index: * Iteration number .index * ``` * * In implicit form: * * ``` * .repeat {5} * Iteration number .1 * ``` * * As with [forEach], the output is a mapping from `[1, N]` to another collection of values. * See [forEach]'s documentation for further details. * * @param times number of times to repeat the content * @param body the output of each iteration. Accepts 1 parameter (the current repetition index, starting from 1). * @return a collection that contains the output of each iteration * @wiki Loops */ fun repeat( times: Int, @LikelyBody body: Lambda, ): IterableValue<OutputValue<*>> = forEach(Range(1, times), body) /** * Custom functions (via [function]) and variables (via [variable]) are saved in a [Library] * whose name begins by this string. */ private const val CUSTOM_FUNCTION_LIBRARY_NAME_PREFIX = "__func__" /** * Defines a custom function that can be called later in the document. * * The function is saved in the current context, and can be shared via mechanisms such as [include] or subdocuments. * * ``` * .function {myfunction} * You have called this function! * ``` * * The function can be called normally: * * ``` * .myfunction * ``` * * The amount of parameters is determined by the amount of **explicit** lambda parameters. * * ``` * .function {add} * a b: * This function has two parameters, `a` and `b`. * ``` * * Arguments can be accessed as in a function call by name: * * ``` * .function {greet} * from to: * **Hello .to** from .from * ``` * * When calling the function, argoments can be positional, named, or a mix of both: * * ``` * .greet {John} {world} * ``` * * ``` * .greet from:{John} to:{world} * ``` * * A parameter might also be optional. In this case, if the corresponding argument is not provided, it will be `none`: * * ``` * .function {greet} * from to?: * **Hello .to** from .from * ``` * * As with any [none] value, operations defined in the `Optionality` stdlib module help dealing with it, * including simulating default values: * * ``` * .function {greet} * from to?: * **Hello .to::otherwise {world}** from .from * ``` * * @param name name of the function * @param body content of the function. Function parameters must be **explicit** lambda parameters * @wiki Declaring functions */ fun function( @Injected context: MutableContext, name: String, @LikelyBody body: Lambda, ): VoidValue { // Function parameters. val parameters = body.explicitParameters.mapIndexed { index, parameter -> FunctionParameter(parameter.name, type = DynamicValue::class, index, parameter.isOptional) } // The custom function itself. val function = SimpleFunction(name, parameters) { bindings, call -> // Retrieving arguments from the function call. // `None` is used as a default value if the argument for an optional parameter is not provided. val args: List<Value<*>> = parameters.map { bindings[it]?.value ?: NoneValue } // The final result is evaluated and returned as a dynamic, hence it can be used as any type. // The calling context is propagated so that dynamic value references within the lambda body // can resolve variables from the calling scope. body.invokeDynamic(args, callingContext = call.context) } // The function is registered and ready to be called. context.libraries += Library(CUSTOM_FUNCTION_LIBRARY_NAME_PREFIX + name, setOf(function)) return VoidValue } /** * Defines a new variable or overwrites an existing one. * * ``` * .var {myvar} {0} * ``` * * Variables can be referenced just like functions: * * ``` * The variable has value .myvar * ``` * * Variables can be reassigned in two ways: * * - By calling the variable as a function with one argument, which is the new value to assign: * ``` * .myvar {42} * ``` * * - By calling this [variable] function again: * ``` * .var {myvar} {42} * ``` * * @param name name of the variable * @param value value to assign * @wiki Variables */ @Name("var") fun variable( @Injected context: MutableContext, name: String, value: DynamicValue, ): VoidValue { val libraryName = CUSTOM_FUNCTION_LIBRARY_NAME_PREFIX + name fun containsVariable(libraries: Set<Library>) = libraries.any { it.name == libraryName } fun removeVariable(libraries: MutableSet<Library>) { libraries.removeIf { it.name == libraryName } } // Attempt to find an existing owner context that already contains the variable, // in case `context` is nested and the owner is up the hierarchy. // If null, either the variable is new or the owner is the root context (because `context` would not be a ScopeContext). // The predicate is non-destructive: it only checks for existence without removing, // as removal side effects during scanning would corrupt contexts that propagated // libraries from a calling scope (e.g. via Lambda.invokeDynamic's callingContext). val potentialOwnerContext: MutableContext? = // Scan contexts upwards until the root. // The last one to contain a matching variable name is the owner of the variable. (context as? ScopeContext)?.lastParentOrNull { it is MutableContext && containsVariable(it.libraries) } as? MutableContext // If an owner has been found, that context is the target context. Otherwise, it is the current one. val targetContext: MutableContext = potentialOwnerContext ?: context // Remove the old variable from the target context, if present, to avoid duplicates and ensure the new value is used. removeVariable(targetContext.libraries) // Also remove from the current context if it holds a separate copy // (e.g. propagated from a calling scope). if (targetContext !== context) { removeVariable(context.libraries) } // In case the value contains function calls, it is evaluated to a value. val evaluated: OutputValue<*> = ValueFactory.eval(value, targetContext) // A variable can be seen as two functions: // - A parameter-less getter that returns the value // - A one-parameter setter that assigns a new value // These two functions are merged into a single one that works via an optional argument, acting both as getter and setter. return function( targetContext, name, Lambda(context, explicitParameters = listOf(LambdaParameter("value", isOptional = true))) { args, _ -> if (args.isEmpty() || args.first() is NoneValue) { // Getter evaluated } else { // Setter val newValue = args.first().let { it as? DynamicValue ?: DynamicValue(it) } // Wrapping the value if needed. variable(targetContext, name, newValue) } }, ) } /** * Defines a temporary variable that lives only inside the lambda body. * * ``` * .let {world} * item: * Hello, .item * ``` * * In implicit form: * * ``` * .let {world} * Hello, .1 * ``` * * @param value value to use as a temporary variable * @param body content to evaluate with the temporary variable. Accepts 1 parameter ([value] itself) * @return the evaluation of [body] with [value] as a parameter * @wiki Let */ fun let( value: DynamicValue, @LikelyBody body: Lambda, ): OutputValue<*> = body.invokeDynamic(value) /** * Creates a null invisible node that forces the expression it lies in to be evaluated as Markdown content. * * This is a workaround that can be used at the beginning of lambda blocks (e.g. in a `.function`, `.if` or `.foreach` call) * in case the visible output does not match the expected one. * * @return an invisible node */ fun node(): NodeValue = BlankNode.wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Icon.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.quarkdown.inline.IconImage import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.value.wrappedAsValue /** * `Icon` stdlib module exporter. * This module handles pixel-perfect icons. */ val Icon: QuarkdownModule = moduleOf( ::icon, ) /** * Shows a pixel-perfect icon, looked up from the icon library by its name. * * Note: icon libraries and names are dependent on the renderer. * No validation is performed at compile time, and missing icons may not be rendered or rendered incorrectly. * * In HTML (and HTML-PDF) rendering, the [Bootstrap Icons](https://icons.getbootstrap.com/#icons) library is used. * * @param name the name of the icon */ fun icon(name: String) = IconImage(name).wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Injection.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.base.block.Html import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.stdlib.internal.applyImportantToCSS /** * `Injection` stdlib module exporter. * This module handles code injection of different languages. */ val Injection: QuarkdownModule = moduleOf( ::html, ::css, ::cssProperties, ) /** * Creates an HTML element, which is rendered as-is without any additional processing or escaping, * as long as the rendering target supports HTML. * * ```html * .html * <div class="my-container"> * My HTML container * </div> * ``` * * ```markdown * **Hello** .html {<em>world</em>}! * ``` * * @param content raw HTML content to inject * @return a new [Html] node * @wiki HTML */ fun html( @LikelyBody content: String, ) = Html(content).wrappedAsValue() /** * Creates a `<style>` HTML element with the provided CSS content. * The content is wrapped in a `<style>` tag and rendered as-is, * without any additional processing or escaping, as long as the rendering target supports HTML. * * Each CSS property value automatically has `!important` applied to it, * unless it already has it. * * ```css * .css * body { * background-color: green; * } * ``` * * @param content raw CSS content to inject * @return a new [Html] node representing the style element * @see [cssProperties] for a more structured way to override CSS properties. * @wiki CSS */ fun css( @LikelyBody content: String, ) = Html("<style data-hidden=\"\">${applyImportantToCSS(content)}</style>").wrappedAsValue() private const val CSS_ROOT_SELECTOR = ":root" private const val CSS_PROPERTY_PREFIX = "--qd-" /** * Overrides the value of Quarkdown CSS properties. * * Each entry corresponds to a Quarkdown CSS property name and its value. * The names will be prefixed with `--qd-` to match the Quarkdown CSS variable naming convention. * * ```yaml * .cssproperties * - background-color: green * - main-font-size: 20px * ``` * * For a complete list of properties, see the [global theme](https://github.com/iamgio/quarkdown/blob/main/quarkdown-html/src/main/scss/global.scss). * Unknown properties will be ignored. * * The content is wrapped in a `<style>` tag and rendered as-is, * without any additional processing or escaping, as long as the rendering target supports HTML. * * @param properties a dictionary of CSS property names and their values * @return a new [Html] node representing the style element * @wiki CSS */ @Name("cssproperties") fun cssProperties(properties: Map<String, Value<*>>) = css( buildString { append(CSS_ROOT_SELECTOR) append(" { ") properties.forEach { (name, value) -> append("$CSS_PROPERTY_PREFIX$name: ${value.unwrappedValue}; ") } append("}") }, ) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Layout.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.ast.quarkdown.block.Box import com.quarkdown.core.ast.quarkdown.block.Clipped import com.quarkdown.core.ast.quarkdown.block.Collapse import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.ast.quarkdown.block.Landscape import com.quarkdown.core.ast.quarkdown.block.Numbered import com.quarkdown.core.ast.quarkdown.block.Stacked import com.quarkdown.core.ast.quarkdown.inline.InlineCollapse import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.ast.quarkdown.inline.Whitespace import com.quarkdown.core.context.Context import com.quarkdown.core.context.localization.localizeOrDefault import com.quarkdown.core.context.localization.localizeOrNull import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.MarkdownContentValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.log.Log import com.quarkdown.core.misc.color.Color import com.quarkdown.core.util.node.toPlainText /** * `Layout` stdlib module exporter. * This module handles position and shape of an element. */ val Layout: QuarkdownModule = moduleOf( ::container, ::align, ::center, ::float, ::row, ::column, ::grid, ::landscape, ::fullColumnSpan, ::whitespace, ::clip, ::box, ::toDo, ::collapse, ::inlineCollapse, ::numbered, ::table, ) /** * A general-purpose container that groups content. * * Any active layout rules inherited by the parent (e.g. from [align], [row], [column], [grid]) are reset inside this container. * * @param width width of the container. No constraint if unset * @param height height of the container. No constraint if unset * @param fullWidth whether the container should take up the full width of the parent. Overridden by [width]. False if unset * @param foregroundColor text color. Default if unset * @param backgroundColor background color. Transparent if unset * @param borderColor border color. Default if unset and [borderWidth] is set * @param borderWidth border width. Default if unset and [borderColor] is set * @param borderStyle border style. Normal (solid) if unset and [borderColor] or [borderWidth] is set * @param margin whitespace outside the content. None if unset * @param padding whitespace around the content. None if unset * @param cornerRadius corner (and border) radius. None if unset * @param alignment alignment of the content. Default if unset * @param textAlignment alignment of the text. [alignment] if unset * @param fontSize relative font size of the text. Normal if unset * @param fontWeight font weight of the text. Normal if unset * @param fontStyle font style of the text. Normal if unset * @param fontVariant font variant of the text. Normal if unset * @param textDecoration text decoration of the text. None if unset * @param textCase text case of the text. Normal if unset * @param float floating position of the container within the parent. Not floating if unset * @param fullColumnSpan whether the container should span across all columns in a multi-column layout. False if unset * @param className CSS class name to apply to the container, if supported by the renderer. None if unset * @param body content to group * @return the new [Container] node * @wiki Container */ fun container( @LikelyNamed width: Size? = null, @LikelyNamed height: Size? = null, @Name("fullwidth") fullWidth: Boolean = false, @Name("foreground") foregroundColor: Color? = null, @Name("background") backgroundColor: Color? = null, @Name("border") borderColor: Color? = null, @Name("borderwidth") borderWidth: Sizes? = null, @Name("borderstyle") borderStyle: Container.BorderStyle? = null, @Name("margin") margin: Sizes? = null, @Name("padding") padding: Sizes? = null, @Name("radius") cornerRadius: Sizes? = null, @LikelyNamed alignment: Container.Alignment? = null, @Name("textalignment") textAlignment: Container.TextAlignment? = alignment?.let(Container.TextAlignment::fromAlignment), @Name("fontsize") fontSize: TextTransformData.Size? = null, @Name("fontweight") fontWeight: TextTransformData.Weight? = null, @Name("fontstyle") fontStyle: TextTransformData.Style? = null, @Name("fontvariant") fontVariant: TextTransformData.Variant? = null, @Name("textdecoration") textDecoration: TextTransformData.Decoration? = null, @Name("textcase") textCase: TextTransformData.Case? = null, @LikelyNamed float: Container.FloatAlignment? = null, @Name("fullspan") fullColumnSpan: Boolean = false, @Name("classname") className: String? = null, @LikelyBody body: MarkdownContent? = null, ) = Container( width, height, fullWidth, foregroundColor, backgroundColor, borderColor, borderWidth, borderStyle, margin, padding, cornerRadius, alignment, textAlignment, TextTransformData(fontSize, fontWeight, fontStyle, textDecoration, textCase, fontVariant), float, fullColumnSpan, className, body?.children ?: emptyList(), ).wrappedAsValue() /** * Aligns content and text within its parent. * * @param alignment content alignment anchor and text alignment * @param body content to center * @return the new aligned [Container] node * @see container * @wiki Align */ fun align( alignment: Container.Alignment, @LikelyBody body: MarkdownContent, ) = container( fullWidth = true, alignment = alignment, textAlignment = Container.TextAlignment.fromAlignment(alignment), body = body, ) /** * Centers content and text within its parent. * * @param body content to center * @return the new aligned [Container] node * @see align * @wiki Align */ fun center( @LikelyBody body: MarkdownContent, ) = align(Container.Alignment.CENTER, body) /** * Turns content into a floating element, allowing subsequent content to wrap around it. * * @param alignment floating position * @param body content to float * @return the new floating [Container] node * @wiki Float */ fun float( @LikelyNamed alignment: Container.FloatAlignment, @LikelyBody body: MarkdownContent, ) = container( float = alignment, body = body, ) /** * Stacks content together, according to the specified type. * @param layout stack type * @param mainAxisAlignment content alignment along the main axis * @param crossAxisAlignment content alignment along the cross axis * @param rowGap blank space between rows. If omitted, the default value is used. Only applicable to [Stacked.Column] and [Stacked.Grid] * @param columnGap blank space between columns. If omitted, the default value is used. Only applicable to [Stacked.Row] and [Stacked.Grid] * @param body content to stack * @return the new [Stacked] node * @see row * @see column * @see grid */ private fun stack( layout: Stacked.Layout, mainAxisAlignment: Stacked.MainAxisAlignment = Stacked.MainAxisAlignment.START, crossAxisAlignment: Stacked.CrossAxisAlignment = Stacked.CrossAxisAlignment.CENTER, rowGap: Size? = null, columnGap: Size? = null, body: MarkdownContent, ) = Stacked(layout, mainAxisAlignment, crossAxisAlignment, rowGap, columnGap, body.children).wrappedAsValue() /** * Stacks content horizontally. * * @param mainAxisAlignment content alignment along the main axis * @param crossAxisAlignment content alignment along the cross axis * @param gap blank space between children. If omitted, the default value is used * @param body content to stack * @return the new [Stacked] node * @wiki Stacks */ fun row( @Name("alignment") mainAxisAlignment: Stacked.MainAxisAlignment = Stacked.MainAxisAlignment.START, @Name("cross") crossAxisAlignment: Stacked.CrossAxisAlignment = Stacked.CrossAxisAlignment.CENTER, @LikelyNamed gap: Size? = null, @LikelyBody body: MarkdownContent, ) = stack(Stacked.Row, mainAxisAlignment, crossAxisAlignment, null, gap, body) /** * Stacks content vertically. * * @param mainAxisAlignment content alignment along the main axis * @param crossAxisAlignment content alignment along the cross axis * @param gap blank space between children. If omitted, the default value is used * @param body content to stack * @return the new [Stacked] node * @wiki Stacks */ fun column( @Name("alignment") mainAxisAlignment: Stacked.MainAxisAlignment = Stacked.MainAxisAlignment.START, @Name("cross") crossAxisAlignment: Stacked.CrossAxisAlignment = Stacked.CrossAxisAlignment.CENTER, @LikelyNamed gap: Size? = null, @LikelyBody body: MarkdownContent, ) = stack(Stacked.Column, mainAxisAlignment, crossAxisAlignment, gap, null, body) /** * Stacks content in a grid layout. * * Each child is placed in a cell, and a row of cells ends when its cell count reaches [columnCount]. * * @param columnCount positive number of columns * @param mainAxisAlignment content alignment along the main axis * @param crossAxisAlignment content alignment along the cross axis * @param gap blank space between rows and columns. If omitted, the default value is used * @param rowGap blank space between rows (overrides [gap] for rows). If omitted, the default value is used * @param columnGap blank space between columns (overrides [gap] for columns). If omitted, the default value is used * @param body content to stack * @return the new [Stacked] node * @throws IllegalArgumentException if [columnCount] is non-positive * @wiki Stacks */ fun grid( @Name("columns") columnCount: Int, @Name("alignment") mainAxisAlignment: Stacked.MainAxisAlignment = Stacked.MainAxisAlignment.CENTER, @Name("cross") crossAxisAlignment: Stacked.CrossAxisAlignment = Stacked.CrossAxisAlignment.CENTER, @LikelyNamed gap: Size? = null, @Name("vgap") rowGap: Size? = gap, @Name("hgap") columnGap: Size? = gap, @LikelyBody body: MarkdownContent, ) = when { columnCount <= 0 -> throw IllegalArgumentException("Column count must be at least 1") else -> stack(Stacked.Grid(columnCount), mainAxisAlignment, crossAxisAlignment, rowGap ?: gap, columnGap ?: gap, body) } /** * Transposes content to landscape orientation by rotating it 90 degrees counter-clockwise. * This is useful for wide content, such as diagrams, that does not fit in the normal page orientation. * * This feature is experimental and may render inconsistently. * @param body content to transpose * @return the new [Landscape] node * @wiki Landscape content */ fun landscape( @LikelyBody body: MarkdownContent, ) = Landscape(body.children).wrappedAsValue() /** * Shorthand for [container] with `fullspan:{true}`. * Makes content span across all columns in a multi-column layout. * * If the document has a single-column layout, the effect is the same as [container]. * * @param body content to span across all columns * @return the new [Container] node with [Container.fullColumnSpan] enabled * @wiki Multi-column layout */ @Name("fullspan") fun fullColumnSpan( @LikelyBody body: MarkdownContent, ) = container(fullColumnSpan = true, body = body) /** * An empty rectangle that adds whitespace to the layout. * * If at least one of the dimensions is set, the rectangle will have a fixed size. * If both dimensions are unset, a blank character (`&nbsp;`) is used, which can be useful for spacing and adding line breaks. * * @param width width of the square. If unset, it defaults to zero * @param height height of the square. If unset, it defaults to zero * @return the new [Whitespace] node */ fun whitespace( @LikelyNamed width: Size? = null, @LikelyNamed height: Size? = null, ) = Whitespace(width, height).wrappedAsValue() /** * Applies a clipping path to its content. * * @param clip clip type to apply * @param body content to clip * @return the new [Clipped] block * @wiki Clip */ fun clip( clip: Clipped.Clip, @LikelyBody body: MarkdownContent, ) = Clipped(clip, body.children).wrappedAsValue() /** * Inserts content in a styled box. * * @param title box title. If unset: * - If the locale ([docLanguage]) is set and supported, the title is localized according to the box [type] * - Otherwise, the box is untitled * @param type box type. If unset, it defaults to a callout box * @param padding padding around the box. If unset, the box uses the default padding * @param backgroundColor background color. If unset, the box uses the default color * @param foregroundColor foreground (text) color. If unset, the box uses the default color * @param body box content * @return the new [Box] node * @wiki Box */ fun box( @Injected context: Context, title: InlineMarkdownContent? = null, @LikelyNamed type: Box.Type = Box.Type.CALLOUT, @LikelyNamed padding: Size? = null, @Name("background") backgroundColor: Color? = null, @Name("foreground") foregroundColor: Color? = null, @LikelyBody body: MarkdownContent, ): NodeValue { // Localizes the title according to the box type, // if the title is not manually set. fun localizedTitle(): InlineContent? = context.localizeOrNull(key = type.name)?.let { buildInline { text(it) } } return Box( title?.children ?: localizedTitle(), type, padding, backgroundColor, foregroundColor, body.children, ).wrappedAsValue() } /** * Creates a _to do_ box, to mark content that needs to be done later, and also logs it to stdout. * * The title is localized according to the current locale ([docLanguage]), or English as a fallback. * * @param body content to show in the box * @return the new box node */ @Name("todo") fun toDo( @Injected context: Context, @LikelyBody body: MarkdownContent, ): NodeValue { val title = context.localizeOrDefault(key = "todo")!! return Box( title = buildInline { text(title.uppercase()) }, type = Box.Type.WARNING, children = body.children, ).wrappedAsValue().also { Log.warn("$title: ${body.children.toPlainText()}") } } /** * Inserts content in a collapsible block, whose content can be hidden or shown by interacting with it. * * @param title title of the block * @param open whether the block is open at the beginning * @param body content of the block when expanded * @return the new [Collapse] node * @wiki Collapsible */ fun collapse( title: InlineMarkdownContent, @LikelyNamed open: Boolean = false, @LikelyBody body: MarkdownContent, ) = Collapse(title.children, open, body.children).wrappedAsValue() /** * Inserts content in a collapsible text span, whose content can be expanded or collapsed by interacting with it. * * @param full content to show when the node is expanded * @param short content to show when the node is collapsed * @param open whether the block is open at the beginning * @return the new [InlineCollapse] node * @wiki Collapsible */ @Name("textcollapse") fun inlineCollapse( @LikelyNamed full: InlineMarkdownContent, @LikelyNamed short: InlineMarkdownContent, @LikelyNamed open: Boolean = false, ) = InlineCollapse(full.children, short.children, open).wrappedAsValue() /** * Node that can be numbered depending on its location in the document * and the amount of occurrences according to its [key]. * * The numbering format can be set via [numbering] by specifying a format for the given [key]. * * ``` * .numbering * - headings: 1.1 * - greetings: 1.a * * # Title 1 * * .numbered {greetings} * number: * Hello! This block has the number .number, which is `1.a`. The next one will be `1.b`. * ``` * * @param key name to group (and count) numbered nodes * @param referenceId optional ID for cross-referencing via [reference] * @param body content, with the formatted location of this element (as a string) as an argument * @return the new [Numbered] node * @wiki Numbering */ fun numbered( @LikelyNamed key: String, @Name("ref") referenceId: String? = null, @LikelyBody body: Lambda, ): NodeValue { val node = Numbered( key, referenceId = referenceId, ) { number -> body .invoke<MarkdownContent, MarkdownContentValue>(number.wrappedAsValue()) .unwrappedValue .children } return node.wrappedAsValue() } /** * Creates a table out of a collection of columns. * * The following example joins 5 columns via [repeat]: * * ``` * .table * .repeat {5} * | Header .1 | * |-----------| * | Cell .1 | * ``` * * @param subTables independent tables (as Markdown sources) that will be parsed and joined together into a single table * @return a new [Table] node * @wiki Table generation */ fun table( @Injected context: Context, @LikelyBody subTables: Iterable<Value<String>>, ): NodeValue { val columns = subTables .asSequence() .map { it.unwrappedValue } .map { ValueFactory.blockMarkdown(it, context).unwrappedValue } .map { it.children.first() } .filterIsInstance<Table>() .flatMap { it.columns } return Table(columns.toList()).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Library.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.context.Context import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.UnorderedCollectionValue import com.quarkdown.core.function.value.wrappedAsValue /** * `Library` stdlib module exporter. * This module handles loaded libraries and their functions. */ val Library: QuarkdownModule = moduleOf( ::libraryExists, ::functionExists, ::libraries, ::libraryFunctions, ) /** * @param context context to search in * @param name name of the library, case-insensitive * @return library with the given name, if it exists */ private fun findLibrary( context: Context, name: String, ): Library? = context.libraries.find { it.name.equals(name, ignoreCase = true) } /** * Checks whether a library with the given name is registered in [context]. * * @param name name of the library, case-insensitive * @return whether a library with the given name is registered in [context] */ @Name("libexists") fun libraryExists( @Injected context: Context, name: String, ) = BooleanValue(findLibrary(context, name) != null) /** * Checks whether a function with the given name is registered in [context]. * * @param name name of the function, case-insensitive * @return whether a function with the given name is registered in [context] */ @Name("functionexists") fun functionExists( @Injected context: Context, name: String, ) = BooleanValue(context.getFunctionByName(name) != null) /** * Lists the names of all libraries loaded in [context]. * * @return an unordered collection of the loaded libraries' names */ fun libraries( @Injected context: Context, ) = UnorderedCollectionValue( context.libraries .asSequence() .map { it.name.wrappedAsValue() } .toSet(), ) /** * Lists the names of all functions exposed by the library with the given name. * * @param libraryName name of the library, case-insensitive * @return unordered set of functions exposed by the library, or an empty one if the library is not found */ @Name("libfunctions") fun libraryFunctions( @Injected context: Context, libraryName: String, ) = UnorderedCollectionValue( findLibrary(context, libraryName) ?.functions ?.asSequence() ?.map { it.name.wrappedAsValue() } ?.toSet() ?: emptySet(), ) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Localization.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.localization.Locale import com.quarkdown.core.localization.LocaleLoader import com.quarkdown.core.localization.LocalizationEntries import com.quarkdown.core.localization.LocalizationTable /** * `Localization` stdlib module exporter. * This module handles localization-related features. */ val Localization: QuarkdownModule = moduleOf( ::localization, ::localize, ) /** * Builds a localization table from the given dictionary of locales and their key-value entries. */ private fun buildLocalizationTable(contents: Map<String, DictionaryValue<OutputValue<String>>>): LocalizationTable = contents .asSequence() .map { (key, value) -> // The locale name is the first element of each list item: // English <-- this is the locale name // - key1: value1 // - key2: value2 val locale: Locale = LocaleLoader.SYSTEM.find(key) ?: throw IllegalArgumentException("Could not find locale \"${key}\".") val entries: LocalizationEntries = value.unwrappedValue.mapValues { (_, value) -> value.unwrappedValue } locale to entries }.toMap() /** * Merges two localization tables, giving priority to the new one. */ private fun mergeLocalizationTables( existingTable: LocalizationTable, newTable: LocalizationTable, ): LocalizationTable = existingTable.toMutableMap().apply { newTable.forEach { (locale, entries) -> merge(locale, entries) { existingEntries, newEntries -> existingEntries + newEntries } } } /** * Defines and registers a new localization table, whose entries are key-value pairs for each locale and defined by a Markdown dictionary. * * ``` * .localization {mytable} * - English * - morning: Good morning * - evening: Good evening * - Italian * - morning: Buongiorno * - evening: Buonasera * ``` * * The localization entries can then be accessed via the [localize] function, after setting the document language via [docLanguage]: * * ``` * .doclang {English} * * .localize {mytable:morning} <!-- Good morning --> * ``` * * If [merge] is set to true, it can be used to expand an existing localization table. * * Example, extending stdlib's localization table: * * ``` * .doclang {fr-CA} * * .localization {std} merge:{true} * - fr-CA * - warning: Avertissement * * .box type:{warning} * Box content * ``` * * In this example, the warning box will automatically feature the "Avertissement" title, * since the `std:warning` localization key is accessed by the `.box` function. * * @param tableName name of the localization table. Must be unique if [merge] is false. * @param merge if enabled and a table with the same name already exists, the two tables will be merged, with higher priority to the new one * @param contents dictionary of locales and their key-value entries * @throws IllegalArgumentException if the contents are not in the correct format, * or if the table name is already defined and [merge] is false * @wiki Localization */ fun localization( @Injected context: MutableContext, @Name("name") tableName: String, @LikelyNamed merge: Boolean = false, @LikelyBody contents: Map<String, DictionaryValue<OutputValue<String>>>, ): VoidValue { val tableExists = tableName in context.localizationTables // Duplicate table names are not allowed. if (!merge && tableExists) { throw IllegalArgumentException( "Localization table \"$tableName\" is already defined. " + "To merge an existing table, use the merge parameter.", ) } var table = buildLocalizationTable(contents) if (merge && tableExists) { val existingTable = context.localizationTables[tableName]!! table = mergeLocalizationTables(existingTable, table) } // The table is registered in the context. context.localizationTables[tableName] = table return VoidValue } /** * Localizes a key from a pre-existing localization table (defined via [localization]). * * ``` * .localize {mytable:key} * ``` * * @param key key to localize, in the format `tableName:keyName` * @param separator separator between the table name and the key name. Defaults to `:` * @return the localized value * @throws com.quarkdown.core.localization.LocalizationException if an error occurs during the lookup * @see localization * @wiki Localization */ fun localize( @Injected context: Context, key: String, @LikelyNamed separator: String = ":", ): StringValue { val (tableName, keyName) = key.split(separator, limit = 2) return context.localize(tableName, keyName).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Logger.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.log.Log /** * `Logger` stdlib module exporter. * This module contains logging utility. */ val Logger: QuarkdownModule = moduleOf( ::log, ::debug, ::error, ) /** * Logs a message (info level) to the standard output. * @param message message to log */ fun log(message: String) = VoidValue.also { Log.info(message) } /** * Logs a message (debug level) to the standard output. * Note that `-Dloglevel=debug` must be enabled to see debug messages. * @param message message to log */ fun debug(message: String) = VoidValue.also { Log.debug(message) } /** * Throws an [Exception] with the given message. * The result depends on the pipeline's error handler. * By default, the message is logged (error level) to the standard output and an error box is rendered on the document. * If the program is run on strict mode, the stack trace is printed and the process will be stopped. * @param message error message */ fun error(message: String) = VoidValue.also { throw Exception(message) } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Logical.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DynamicValue /** * `Logical` stdlib module exporter. */ val Logical: QuarkdownModule = moduleOf( ::isLower, ::isGreater, ::equals, ::not, ) /** * @param a first number to compare * @param b second number to compare * @param equals whether the comparison should be 'lower or equals' instead * @return whether `a < b` (or `<=` if [equals] is `true`) */ @Name("islower") @LikelyChained fun isLower( a: Number, @Name("than") b: Number, @Name("orequals") equals: Boolean = false, ) = BooleanValue( if (equals) { a.toFloat() <= b.toFloat() } else { a.toFloat() < b.toFloat() }, ) /** * @param a first number to compare * @param b second number to compare * @param equals whether the comparison should be 'greater or equals' instead * @return whether `a > b` (or `>=` if [equals] is `true`) */ @Name("isgreater") @LikelyChained fun isGreater( a: Number, @Name("than") b: Number, @Name("orequals") equals: Boolean = false, ) = BooleanValue( if (equals) { a.toFloat() >= b.toFloat() } else { a.toFloat() > b.toFloat() }, ) /** * Compares two values for equality. * @param a first value to compare * @param b second value to compare * @return whether [a] and [b] have equal content */ @Name("equals") @LikelyChained fun equals( a: DynamicValue, @Name("to") b: DynamicValue, ) = BooleanValue(a == b || a.unwrappedValue == b.unwrappedValue) /** * Negates a boolean value. * @param value boolean value to negate * @return the negation of [value] */ @LikelyChained fun not(value: Boolean) = BooleanValue(!value) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Math.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.NumberValue import com.quarkdown.core.function.value.ObjectValue import com.quarkdown.core.function.value.data.Range import kotlin.math.PI import kotlin.math.pow /** * `Math` stdlib module exporter. */ val Math: QuarkdownModule = moduleOf( ::sum, ::subtract, ::multiply, ::divide, ::rem, ::pow, ::abs, ::negate, ::sqrt, ::logn, ::pi, ::sin, ::cos, ::tan, ::truncate, ::round, ::isEven, ::range, ) // Basic operations /** * @param a first operand * @param b second operand * @return arithmetic floating-point sum of [a] and [b] */ @LikelyChained fun sum( a: Number, b: Number, ) = NumberValue(a.toFloat() + b.toFloat()) /** * @param a first operand * @param b second operand * @return arithmetic floating-point subtraction of [a] and [b] */ @LikelyChained fun subtract( a: Number, b: Number, ) = NumberValue(a.toFloat() - b.toFloat()) /** * @param a first operand * @param b second operand * @return arithmetic floating-point multiplication of [a] and [b] */ @LikelyChained fun multiply( a: Number, @Name("by") b: Number, ) = NumberValue(a.toFloat() * b.toFloat()) /** * @param a first operand * @param b second operand * @return arithmetic floating-point division of [a] and [b] */ @LikelyChained fun divide( a: Number, @Name("by") b: Number, ) = NumberValue(a.toFloat() / b.toFloat()) /** * @param a first operand * @param b second operand * @return remainder of the arithmetic floating-point division of [a] and [b] */ @LikelyChained fun rem( a: Number, b: Number, ) = NumberValue(a.toFloat() % b.toFloat()) /** * @param base base number * @param exponent exponent number. If it is a floating-point number, it will be truncated to an integer * @return [base] raised to the power of [exponent] */ @LikelyChained fun pow( base: Number, @Name("to") exponent: Number, ) = NumberValue(base.toFloat().pow(exponent.toInt())) /** * @param x number to get the absolute value of * @return the absolute value of [x] */ @LikelyChained fun abs(x: Number) = when (x) { is Int -> kotlin.math.abs(x) else -> kotlin.math.abs(x.toFloat()) }.let(::NumberValue) /** * @param x the number to negate (positive to negative and vice versa) * @return the negation of [x] */ @LikelyChained fun negate(x: Number) = when (x) { is Int -> -x else -> -x.toFloat() }.let(::NumberValue) /** * @param x number to get the square root of * @return the square root of [x] */ @LikelyChained fun sqrt(x: Number) = kotlin.math.sqrt(x.toFloat()).let(::NumberValue) /** * @param x number to get the natural logarithm of * @return the natural logarithm of [x] */ @LikelyChained fun logn(x: Number) = kotlin.math.ln(x.toFloat()).let(::NumberValue) // Trigonometry /** * @return the value of pi */ fun pi() = NumberValue(PI) /** * @param x angle in radians * @return sine of the angle [x] */ fun sin(x: Number) = NumberValue(kotlin.math.sin(x.toFloat())) /** * @param x angle in radians * @return cosine of the angle [x] */ fun cos(x: Number) = NumberValue(kotlin.math.cos(x.toFloat())) /** * @param x angle in radians * @return tangent of the angle [x] */ fun tan(x: Number) = NumberValue(kotlin.math.tan(x.toFloat())) // Decimals /** * Truncates a floating-point number to a specified number of decimal places. * @param x number to truncate * @param decimals maximum number of decimal places to keep. Must be a non-negative number * @return [x] truncated to [decimals] decimal places. If [decimals] is 0, the number is truncated to an integer, * otherwise to a floating-point number * @throws IllegalArgumentException if [decimals] is negative */ @LikelyChained fun truncate( x: Number, @LikelyNamed decimals: Int, ): NumberValue = when { decimals < 0 -> throw IllegalArgumentException("Decimals must be a non-negative number") decimals == 0 -> x.toInt() x is Int -> x else -> { val multiplier = 10.0.pow(decimals) (x.toFloat() * multiplier).toInt() / multiplier.toFloat() } }.let(::NumberValue) /** * Rounds a floating-point number to the nearest integer. * @param x number to round * @return [x] rounded to the nearest integer */ @LikelyChained fun round(x: Number): NumberValue = when (x) { is Int -> x else -> kotlin.math.round(x.toFloat()).toInt() }.let(::NumberValue) // Misc /** * @param x number to check * @return whether the integer value of [x] is an even number */ @Name("iseven") @LikelyChained fun isEven(x: Number) = BooleanValue(x.toInt() % 2 == 0) /** * Creates a range of numbers, which can also be iterated through. * * The behavior of an open range is delegated to the consumer. * For instance, using a left-open range with [forEach] will make the loop start from 1. * * The difference between this function and the built-in `..` operator is that the latter * does not allow for dynamic evaluation, hence both ends must be literals. * This function allows evaluating ends dynamically: for instance, `.range from:{1} to:{.sum {1} {2}}`. * * Floating-point numbers are truncated to integers. * @param start start of the range (inclusive). If unset, the range is infinite on the left end * @param end end of the range (inclusive). If unset, the range is infinite on the right end. [end] > [start] */ fun range( @Name("from") start: Number? = null, @Name("to") end: Number? = null, ): ObjectValue<Range> = ObjectValue( Range(start?.toInt(), end?.toInt()), ) ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Mermaid.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.MermaidDiagram import com.quarkdown.core.ast.quarkdown.block.SubdocumentGraph import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.Value import com.quarkdown.core.function.value.data.EvaluableString import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.util.indent import com.quarkdown.stdlib.internal.asDouble /** * `Mermaid` stdlib module exporter. * This module handles generation of Mermaid diagrams. */ val Mermaid: QuarkdownModule = moduleOf( ::mermaid, ::xyChart, ::subdocumentGraph, ) private fun mermaidFigure( caption: String?, referenceId: String? = null, code: String, ) = Figure<MermaidDiagram>( MermaidDiagram(code), caption = caption, referenceId = referenceId, ).wrappedAsValue() /** * Creates a Mermaid diagram. * * If either [caption] or [referenceId] is set, the diagram will be numbered as a figure. * * ``` * .mermaid * graph TD * A --> B * B --> C * ``` * * Content can also be loaded from a file via [read]: * * ``` * .mermaid * .read {path/to/diagram.mmd} * ``` * * @param caption optional caption * @param referenceId optional ID for cross-referencing via [reference] * @param code the Mermaid code of the diagram * @return a new [Figure] node */ fun mermaid( @LikelyNamed caption: String? = null, @Name("ref") referenceId: String? = null, @LikelyBody code: EvaluableString, ) = mermaidFigure( caption = caption, referenceId = referenceId, code = code.content, ) /** * A chart line is a list of its points. */ private typealias ChartLine = List<Double> /** * Extracts the lines from the given values. * If a value in [values] is a collection of points, then it's a line. * Any other value is considered a standalone point, and an additional line is created for them. * @param values the values to extract lines from * @return a list of chart lines */ private fun extractLines(values: Iterable<OutputValue<*>>): List<ChartLine> { val (lines, points) = values.partition { it is IterableValue<*> } val lineOfPoints: ChartLine = points.map { it.asDouble() } return lines .asSequence() .map { it as IterableValue<*> } .filterNot { it.unwrappedValue.none() } .map { it.unwrappedValue.map { point -> point.asDouble() } } .toMutableList() .apply { if (lineOfPoints.isNotEmpty()) add(lineOfPoints) } } /** * Appends the axis definition to the given [StringBuilder]. * @param name name of the axis (e.g. "x" or "y") * @param label optional label of the axis * @param range optional range of the axis * @param tags optional categorical tags of the axis * @param min minimum value of the plotted points along the axis * @param max maximum value of the plotted points along the axis */ private fun StringBuilder.axis( name: String, label: String?, range: Range?, tags: Iterable<Value<*>>?, min: Double, max: Double, ) { if (label == null && range == null && tags == null) return require(!(range != null && tags != null)) { "An XY chart axis cannot feature both numeric range and categorical tags." } append("\n") append(name).append("-axis") label?.let { append(" \"") append(it) append("\"") } range?.let { append(" ") append(it.start ?: min) append(" --> ") append(it.end ?: max) } tags?.let { append(" ") append(it.map { tag -> tag.unwrappedValue }) } } /** * Creates a chart diagram on the XY plane. * * The following example plots 4 points at (1, 5), (2, 2), (3, 4), (4, 10), connected by a line: * * ``` * .xychart * - 5 * - 2 * - 4 * - 10 * ``` * * Multiple lines can be plotted by supplying a list of lists of points. Each list will be plotted as a separate line: * * ``` * .xychart * - - 5 * - 8 * - 3 * - - 3 * - 5 * - 10 * - - 8 * - 3 * - 5 * ``` * * Complex plottings can be achieved by manipulating collections, for instance via [forEach] or [repeat]: * * ``` * .xychart * .repeat {100} * n: * .n::pow {2}::divide {100} * ``` * * @param showLines whether to draw lines * @param showBars whether to draw bars * @param xAxisLabel optional label for the X axis * @param xAxisRange optional range for the X axis. If open-ended, the range will be set to the minimum and maximum values of the X values. Incompatible with [xAxisTags]. * @param xAxisTags optional categorical tags for the X axis. Incompatible with [xAxisRange]. * @param yAxisLabel optional label for the Y axis * @param yAxisRange optional range for the Y axis. If open-ended, the range will be set to the minimum and maximum values of the Y values * @param caption optional caption. If a caption is present, the diagram will be numbered as a figure * @param referenceId optional ID for cross-referencing via [reference] * @param values the Y values to plot. * They can be a list of points, which will be plotted as a single line, * or a list of lists of points, which will be plotted as multiple lines. * @return the generated diagram node * @throws IllegalArgumentException if both [xAxisRange] and [xAxisTags] are set * @wiki XY chart */ @Name("xychart") fun xyChart( @Name("lines") showLines: Boolean = true, @Name("bars") showBars: Boolean = false, @Name("x") xAxisLabel: String? = null, @Name("xrange") xAxisRange: Range? = null, @Name("xtags") xAxisTags: Iterable<Value<*>>? = null, @Name("y") yAxisLabel: String? = null, @Name("yrange") yAxisRange: Range? = null, @LikelyNamed caption: String? = null, @Name("ref") referenceId: String? = null, @LikelyBody values: Iterable<OutputValue<*>>, ): NodeValue { val lines: List<ChartLine> = extractLines(values) val (minY, maxY) = lines.flatten().let { (it.minOrNull() ?: 0.0) to (it.maxOrNull() ?: 1.0) } val (minX, maxX) = 0.0 to (lines.maxByOrNull { it.size }?.size?.toDouble() ?: 1.0) val content = buildString { axis("x", xAxisLabel, xAxisRange, xAxisTags, minX, maxX) axis("y", yAxisLabel, yAxisRange, null, minY, maxY) lines.forEach { points -> if (showBars) { append("\n") append("bar ") append(points) } if (showLines) { append("\n") append("line ") append(points) } } } val code = "xychart-beta\n" + content.indent("\t") return mermaidFigure(caption = caption, referenceId = referenceId, code = code) } /** * Displays a directed graph of subdocuments within the document. * * A subdocument is an independent unit of content, usually contained in a separate file, * that can be linked to from the main document or other subdocuments. * * This graph visualizes the relationships between subdocuments, * showing how they are connected through links. * * @return a new [SubdocumentGraph] node * @wiki Subdocuments */ @Name("subdocumentgraph") fun subdocumentGraph() = SubdocumentGraph().wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/MiscElements.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.stdlib.internal.fileTreeFromList /** * `MiscElements` stdlib module exporter. * This module handles miscellaneous elements that do not fit in other modules. */ val MiscElements: QuarkdownModule = moduleOf( ::fileTree, ) /** * Creates a visual file tree from a Markdown list. * Each inline item is rendered as a file, and each nested list as a directory. * * Example: * ``` * .filetree * - src * - main.ts * - utils.ts * - README.md * ``` * @param content body content containing a Markdown list that defines the file tree structure * @return the generated [FileTree] node */ @Name("filetree") fun fileTree( @LikelyBody content: MarkdownContent, ): NodeValue { val rawListNode = content.children.firstOrNull() as? ListBlock ?: throw IllegalArgumentException("Content of file tree must be a list.") return FileTree(fileTreeFromList(rawListNode)).wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Optionality.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.None import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.wrappedAsValue /** * `Optionality` stdlib module exporter. * This module handles `None` values to express optional values. */ val Optionality: QuarkdownModule = moduleOf( ::none, ::isNone, ::otherwise, ::ifPresent, ::takeIf, ) /** * @return a value which represents nothing * @wiki None */ fun none() = NoneValue /** * @param value value to check * @return whether [value] represents a `None` value * @see [none] */ internal fun isNone(value: Any?) = value == null || value is None || value is NoneValue /** * Checks whether [value] represents a [none] value. * * ``` * .none::isnone <!-- True --> * ``` * * @param value value to check * @return whether [value] represents a [none] value * @see [none] * @wiki None */ @Name("isnone") @LikelyChained fun isNone(value: DynamicValue): BooleanValue = isNone(value.unwrappedValue).wrappedAsValue() /** * Returns [value] if it is not [none], [fallback] otherwise. * * ``` * Hi! I'm .name::otherwise {unnamed} * ``` * * @param value value to check * @param fallback value to return if [value] is [none] * @return [value] if it is not [none], [fallback] otherwise * @see isNone * @wiki None */ @LikelyChained fun otherwise( value: DynamicValue, @LikelyNamed fallback: DynamicValue, ): DynamicValue = if (isNone(value.unwrappedValue)) fallback else value /** * Maps [value] to the result of [mapping]. * * Note: this function is usually inlined. When inlining lambda arguments, an explicit `@lambda` annotation is required: * * ``` * .name::ifpresent {@lambda x: .x::uppercase}::otherwise {unnamed} * ``` * * As with any inlined lambda, `@lambda` can be omitted if the result is constant: * * ``` * .name::ifpresent {I have a name}::otherwise {I'm unnamed} * ``` * * @param value value to check * @param mapping lambda to execute if [value] is not [none]. * It should accept one argument, which is [value], and return a value. * @return the result of [mapping] executed on [value] if [value] is not [none], [none] otherwise * @see isNone * @wiki None */ @LikelyChained @Name("ifpresent") fun ifPresent( value: DynamicValue, mapping: Lambda, ): OutputValue<*> = if (!isNone(value.unwrappedValue)) mapping.invokeDynamic(value) else NoneValue /** * Keeps [value] if [condition] is true, otherwise returns [none]. * * Note: this function is usually inlined. When inlining lambda arguments, an explicit `@lambda` annotation is required: * * ``` * .takeif {5} {@lambda x: .x::iseven} <!-- None --> * ``` * * This function is particularly useful when chained, for example: * * ``` * .sum {2} {3}::takeif {@lambda x: .iseven {.x}}::otherwise {0} <!-- 0 --> * ``` * * @param value value to check * @param condition condition to check, which accepts one argument ([value]) and returns a boolean * @return [value] if the result of [condition] is true, [none] otherwise * @wiki None */ @LikelyChained @Name("takeif") fun takeIf( value: DynamicValue, condition: Lambda, ): OutputValue<*> = if (condition.invoke<Boolean, BooleanValue>(value).unwrappedValue) value else NoneValue ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Primitives.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.block.Heading import com.quarkdown.core.ast.quarkdown.block.Figure import com.quarkdown.core.ast.quarkdown.block.PageBreak import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.wrappedAsValue /** * `Primitives` stdlib module exporter. * This module handles wrappers of primitive Markdown nodes with more granular control. */ val Primitives: QuarkdownModule = moduleOf( ::heading, ::pageBreak, ::figure, ) /** * Creates a heading with fine-grained control over its behavior. * * Unlike standard Markdown headings (`#`, `##`, etc.), this function allows explicit control * over numbering, page breaks, table of contents indexing, and custom identifiers. * * Example: * ```markdown * .heading {My heading} depth:{2} numbered:{no} * ``` * * @param content inline content of the heading * @param depth importance level of the heading (1 for H1, 6 for H6). For 0-depth, see [marker] instead * @param customId optional custom identifier for cross-referencing. If unset, the ID is automatically generated * @param canTrackLocation whether the heading **can** be numbered and has its position tracked in the document hierarchy. * Actual numbering depends on [numbering]. * @param includeInTableOfContents whether the heading should appear in the table of contents and navigation sidebar. * Can be used independently from [canTrackLocation]. * @param canBreakPage whether the heading triggers an automatic page break * @return a wrapped [Heading] node * @throws IllegalArgumentException if [depth] is not in the 1-6 range */ fun heading( content: InlineMarkdownContent, @LikelyNamed depth: Int, @Name("ref") customId: String? = null, @Name("numbered") canTrackLocation: Boolean = true, @Name("indexed") includeInTableOfContents: Boolean = true, @Name("breakpage") canBreakPage: Boolean = true, ): NodeValue { require(depth in Heading.MIN_DEPTH..Heading.MAX_DEPTH) { "Heading depth must be between ${Heading.MIN_DEPTH} and ${Heading.MAX_DEPTH}, but got $depth." } return Heading( depth = depth, text = content.children, customId = customId, canBreakPage = canBreakPage, canTrackLocation = canTrackLocation, excludeFromTableOfContents = !includeInTableOfContents, ).let(::NodeValue) } /** * Creates a page break. In standard Quarkdown, this is also achievable with `<<<` on its own line, * but this function provides a more explicit way to insert a page break, * and can be used within other function calls. * * Example: * ```markdown * .pagebreak * ``` * * @return a [PageBreak] node */ @Name("pagebreak") fun pageBreak() = PageBreak().wrappedAsValue() /** * Inserts content in a figure block, with an optional caption. * * If either [caption] or [referenceId] is set, the figure will be numbered according to the `figures` [numbering] rule. * * @param caption optional caption of the figure * @param referenceId optional ID for cross-referencing via [reference] * @param body content of the figure * @return the new [Figure] node */ fun figure( @LikelyNamed caption: String? = null, @Name("ref") referenceId: String? = null, @LikelyBody body: MarkdownContent, ): NodeValue = Figure<MarkdownContent>( body, caption = caption, referenceId = referenceId, ).wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Reference.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.quarkdown.reference.CrossReference import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.wrappedAsValue /** * `Reference` stdlib module exporter. * This module handles cross-references. * @see com.quarkdown.core.ast.quarkdown.reference */ val Reference: QuarkdownModule = moduleOf( ::reference, ) /** * Creates a reference to a target node with a matching ID. * * Examples of referenceable nodes include: * * - Headings * * ```markdown * # Heading {#id} * ``` * * - Figures * * ```markdown * ![Alt](image.png "Caption"){#id} * ``` * * - Tables * * ```markdown * | Header | Header | * |--------|--------| * | Cell | Cell | * {#id} * ``` * * - Code blocks * * ~~~markdown * ```python {#id} * print("Hello, World!") * ``` * ~~~ * * - Custom [numbered] blocks * * ```markdown * .numbered {key} ref:{id} * ``` * * The reference is successfully resolved if the ID matches that of a referenceable node in the document: * * ``` * .ref {id} * ``` * * @param id the reference ID of the target node being referenced * @return a [CrossReference] to the target node * @wiki Cross-references */ @Name("ref") fun reference(id: String) = CrossReference(id).wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Slides.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.quarkdown.block.SlidesFragment import com.quarkdown.core.ast.quarkdown.block.SlidesSpeakerNote import com.quarkdown.core.ast.quarkdown.invisible.SlidesConfigurationInitializer import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.slides.Transition import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.reflect.annotation.OnlyForDocumentType import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.wrappedAsValue /** * `Slides` stdlib module exporter. * This module handles slides properties. */ val Slides: QuarkdownModule = moduleOf( ::setSlidesConfiguration, ::fragment, ::speakerNote, ) /** * Sets global properties that affect the behavior of a 'slides' document. * * @param center whether slides should be centered vertically * @param showControls whether navigation controls should be shown * @param showNotes whether speaker notes should be shown when not in speaker view * @param transitionStyle global transition style between slides * @param transitionSpeed global transition speed between slides * @return a new [SlidesConfigurationInitializer] node * @wiki Slides configuration */ @OnlyForDocumentType(DocumentType.SLIDES) @Name("slides") fun setSlidesConfiguration( @LikelyNamed center: Boolean? = null, @Name("controls") showControls: Boolean? = null, @Name("speakernotes") showNotes: Boolean? = null, @Name("transition") transitionStyle: Transition.Style? = null, @Name("speed") transitionSpeed: Transition.Speed = Transition.Speed.DEFAULT, ): NodeValue = SlidesConfigurationInitializer( center, showControls, showNotes, transitionStyle?.let { Transition(it, transitionSpeed) }, ).wrappedAsValue() /** * Creates an element that, when used in a `slides` document, * shows its content when the user attempts to go to the next slide. * * Multiple fragments in the same slide are shown in order on distinct user interactions. * * @param behavior visibility type of the fragment and how it reacts to user interactions * @param content content to show/hide * @return a new [SlidesFragment] node * @wiki Slides fragment */ @OnlyForDocumentType(DocumentType.SLIDES) fun fragment( behavior: SlidesFragment.Behavior = SlidesFragment.Behavior.SHOW, @LikelyBody content: MarkdownContent, ) = SlidesFragment(behavior, content.children).wrappedAsValue() /** * Creates a speaker note for a `slides` document. * * Speaker notes are visible only to the presenter and not to the audience during a presentation. * In Reveal.js, speaker notes are shown in the speaker view (enabled by pressing `S`). * * @param content the content of the note * @return a new [SlidesSpeakerNote] node * @wiki Slides speaker note */ @OnlyForDocumentType(DocumentType.SLIDES) @Name("speakernote") fun speakerNote( @LikelyBody content: MarkdownContent, ): NodeValue = SlidesSpeakerNote(content.children).wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Stdlib.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.LibraryExporter import com.quarkdown.core.function.library.loader.MultiFunctionLibraryLoader import com.quarkdown.core.function.value.NoneValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.pipeline.PipelineHooks /** * Fallback value for non-existent elements in collections, dictionaries, and more. */ val NOT_FOUND: OutputValue<*> get() = NoneValue /** * Exporter of Quarkdown's standard library. */ object Stdlib : LibraryExporter { override val library: Library get() = MultiFunctionLibraryLoader(name = "stdlib") .load( Document, Layout, Text, Primitives, MiscElements, Math, Logical, String, Icon, Emoji, Collection, Dictionary, Optionality, Logger, Flow, TableComputation, Data, Localization, Library, Slides, Ecosystem, Injection, Mermaid, Reference, Bibliography, ).withHooks( PipelineHooks( // Localization data is loaded before any function is called. afterRegisteringLibraries = { includeResource( this.readOnlyContext, javaClass.getResourceAsStream("/lib/localization.qd")!!.reader(), ) }, ), ) } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/String.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyChained import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.util.StringCase import com.quarkdown.core.util.case import com.quarkdown.core.util.trimDelimiters /** * `String` stdlib module exporter. * This module handles string manipulation. */ val String: QuarkdownModule = moduleOf( ::string, ::concatenate, ::uppercase, ::lowercase, ::capitalize, ::isEmpty, ::isNotEmpty, ) /** * Creates a string. * * If [value] is delimited by `"` characters, they are removed * and the wrapped string is not trimmed, as opposed to what usually happens * through Quarkdown's parser. * * Example: `" Hello, World! "` -> ` Hello, World! ` * @param value string to wrap * @return a new string value */ fun string(value: String) = when { value.firstOrNull() == '\"' && value.lastOrNull() == '\"' -> value.trimDelimiters() else -> value }.wrappedAsValue() /** * Concatenates two strings if a condition is met. * * ``` * .concatenate {abc} with:{def} <!-- abcdef --> * ``` * * ``` * .var {condition} {no} * * .concatenate {abc} with:{def} if:{.condition} <!-- abc --> * ``` * * @param a first string * @param b second string * @param condition if true, concatenates `a` and `b` * @return a new string that is the concatenation of `a` and `b` if `condition` is true, `a` otherwise */ @LikelyChained fun concatenate( a: String, @Name("with") b: String, @Name("if") condition: Boolean = true, ): StringValue = when { condition -> a + b else -> a }.wrappedAsValue() /** * Converts a string to uppercase. * * Example: `Hello, World!` -> `HELLO, WORLD! * * @param string string to convert * @return a new uppercase string */ @LikelyChained fun uppercase(string: String) = string.case(StringCase.Upper).wrappedAsValue() /** * Converts a string to lowercase. * * Example: `Hello, World!` -> `hello, world!` * * @param string string to convert * @return a new lowercase string */ @LikelyChained fun lowercase(string: String) = string.case(StringCase.Lower).wrappedAsValue() /** * Capitalizes the first character of a string. * * Example: `hello, world!` -> `Hello, world!` * * @param string string to capitalize * @return a new string with the first character capitalized */ @LikelyChained fun capitalize(string: String) = string.case(StringCase.Capitalize).wrappedAsValue() /** * Checks if a string is empty. * * @param string string to check * @return `true` if the string is empty, `false` otherwise */ @LikelyChained @Name("isempty") fun isEmpty(string: String) = string.isEmpty().wrappedAsValue() /** * Checks if a string is not empty. * * @param string string to check * @return `true` if the string is not empty, `false` otherwise * @see isEmpty */ @Name("isnotempty") @LikelyChained fun isNotEmpty(string: String) = string.isNotEmpty().wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/TableComputation.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.context.Context import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.Injected import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.BooleanValue import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.IterableValue import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.OrderedCollectionValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.util.node.toPlainText import com.quarkdown.stdlib.internal.AlphanumericComparator import com.quarkdown.stdlib.internal.Ordering import com.quarkdown.stdlib.internal.sortedBy /** * `TableComputation` stdlib module exporter. * This module provides advanced functionality for tables, enhancing their capabilities * beyond basic data representation. * It adds dynamic operations like sorting, filtering, calculations. */ val TableComputation: QuarkdownModule = moduleOf( ::tableSort, ::tableFilter, ::tableCompute, ::tableColumn, ::tableColumns, ::generateTableByRows, ) /** * Finds a table nested in a given content. * The reason to go deeper instead of searching just in the first layer is because of function calls: * a function call node is a [NestableNode] which saves its output content into its children. * This allows to use table computation features not only on Markdown tables, but also on * function-generated tables, for example via [csv]. * @param content the content to search for a table * @return the first table found * @throws IllegalArgumentException if no table is found */ private fun findTable(content: NestableNode): Table = when (val child = content.children.firstOrNull()) { is Table -> child is NestableNode -> findTable(child) else -> throw IllegalArgumentException("A table is not provided and cannot be found.") } /** * Retrieves a specific column from a table. * @param table the table to extract the column from * @param columnIndex index of the column (starting from 1) * @return a triple containing the table, the column, and the cells of the column, in order, as strings * @throws IllegalArgumentException if the column index is out of bounds */ private fun getTableColumn( table: Table, columnIndex: Int, ): Triple<Table, Table.Column, List<String>> { // Index starts from 1. val normalizedColumnIndex = columnIndex - INDEX_STARTS_AT val column = table.columns.getOrNull(normalizedColumnIndex) ?: throw IllegalArgumentException("Column index must be between 1 and ${table.columns.size}.") val values = column.cells.map { it.text.toPlainText() } return Triple(table, column, values) } /** * Retrieves a specific column from a table nested in a given content. * @param content the content to search for a table * @param columnIndex index of the column (starting from 1) * @return a triple containing the table, the column, and the cells of the column, in order, as strings * @throws IllegalArgumentException if no table is found or if the column index is out of bounds */ private fun findTableColumn( content: NestableNode, columnIndex: Int, ) = getTableColumn(findTable(content), columnIndex) /** * Edits a table by replacing its columns with the specified ones. * @param table the original table * @param columns the new columns to replace the original ones * @return a new table with the specified columns, and the same other properties as the original */ private fun editTable( table: Table, columns: List<Table.Column>, ) = Table(columns, table.caption) /** * Reconstructs a table based on the specified row indexes. * @param table the original table * @param orderedRowIndexes the list of ordered row indexes * @return a new table with the same content as [table], * with the rows rearranged or filtered according to the new indexes */ private fun reconstructTable( table: Table, orderedRowIndexes: List<Int>, ): Table { val newColumns = table.columns.map { it.copy(cells = orderedRowIndexes.map(it.cells::get)) } return editTable(table, newColumns) } /** * Sorts a table based on the values of a column. * * The sorting is done alphanumerically, hence, for example, `$120` > `$30`, as opposed to the usual lexicographical order. * * Example: * ``` * .tablesort {2} * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * Result: * ``` * | Name | Age | City | * |------|-----|------| * | Mike | 19 | CHI | * | John | 25 | NY | * | Lisa | 32 | LA | * ``` * * @param column index of the column (starting from 1) * @param order sorting order (`ascending` or `descending`) * @param content table to sort * @return the sorted [Table] node * @wiki Table manipulation */ @Name("tablesort") fun tableSort( @Name("column") columnIndex: Int, order: Ordering = Ordering.ASCENDING, @Name("table") @LikelyBody content: MarkdownContent, ): NodeValue { val (table, _, values) = findTableColumn(content, columnIndex) // Obtain the indexes of the rows sorted by the reference column. val orderedRowIndexes: List<Int> = values .asSequence() .withIndex() .sortedBy(order, AlphanumericComparator) { item -> item.value } .map { it.index } .toList() return reconstructTable(table, orderedRowIndexes).wrappedAsValue() } /** * Filters the rows of a table based on a boolean expression on a specific column. * * Example: * ``` * .tablefilter {2} {@lambda x: .x::isgreater {20}} * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * Result: * ``` * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * ``` * * @param column index of the column (starting from 1) * @param filter a lambda function that returns a boolean value. When `true`, the rows is to be kept. * The lambda accepts a single argument, which is the cell value of the column. * @param content table to filter * @return the filtered [Table] node * @wiki Table manipulation */ @Name("tablefilter") fun tableFilter( @Name("column") columnIndex: Int, filter: Lambda, @Name("table") @LikelyBody content: MarkdownContent, ): NodeValue { val (table, _, values) = findTableColumn(content, columnIndex) val filteredRowIndexes = values .withIndex() .filter { item -> filter.invoke<Boolean, BooleanValue>(DynamicValue(item.value)).unwrappedValue } .map { it.index } return reconstructTable(table, filteredRowIndexes).wrappedAsValue() } /** * Performs a computation on a specific column of a table, appending the result to a new cell in the bottom. * * Example: * ``` * .tablecompute {2} {@lambda x: .x::average::round} * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * Result: * ``` * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * | | 25 | | * ``` * * @param column index of the column (starting from 1) * @param compute a lambda function that returns any value, which is the output of the computation. * The lambda accepts a single argument, which is the ordered collection of cell values of the column. * @param content table to compute on * @return the computed [Table] node, of size `columns * (rows + 1)` * @wiki Table manipulation */ @Name("tablecompute") fun tableCompute( @Name("column") columnIndex: Int, compute: Lambda, @Name("table") @LikelyBody content: MarkdownContent, ): NodeValue { val (table, column, values) = findTableColumn(content, columnIndex) // `compute` is called with the collection of cell values as an argument. val cellValuesCollection = OrderedCollectionValue(values.map(::DynamicValue)) val computedCell = compute.invokeDynamic(cellValuesCollection).unwrappedValue // Append the computed cell to the column, and empty cells to the others. val newColumns = table.columns.map { val resultCell = Table.Cell( buildInline { if (it === column) text(computedCell.toString()) }, ) it.copy(cells = it.cells + resultCell) } return editTable(table, newColumns).wrappedAsValue() } /** * Retrieves a specific column from a table as a collection of values. * * Example: * ``` * .tablecolumn {2} * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * Result: * ``` * - 25 * - 32 * - 19 * ``` * * @param column index of the column (starting from 1) * @param content table to extract the column from * @return the extracted cells * @wiki Table manipulation */ @Name("tablecolumn") fun tableColumn( @Name("column") columnIndex: Int, @Name("of") @LikelyBody content: MarkdownContent, ): IterableValue<OutputValue<*>> { val (_, _, values) = findTableColumn(content, columnIndex) return OrderedCollectionValue(values.map(::DynamicValue)) } /** * Retrieves all columns from a table as a collection of collections. * * Example: * ``` * .tablecolumns * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * Result: * ``` * - - John * - Lisa * - Mike * * - - 25 * - 32 * - 19 * * - - NY * - LA * - CHI * ``` * * @param content table to extract the columns from * @return the extracted cells, grouped by column * @wiki Table manipulation */ @Name("tablecolumns") fun tableColumns( @Name("of") @LikelyBody content: MarkdownContent, ): IterableValue<IterableValue<out OutputValue<*>>> { val table = findTable(content) return table.columns .mapIndexed { index, column -> val (_, _, values) = getTableColumn(table, index + INDEX_STARTS_AT) values.map(::DynamicValue).wrappedAsValue() }.wrappedAsValue() } /** * Generates a table from a list of rows, where each row is a list of cell values. * Optionally, headers can be provided for the columns. * * Example: * ``` * .var {headers} * - Name * - Age * - City * * .tablebyrows {.headers} * - - John * - 25 * - NY * - - Lisa * - 32 * - LA * - - Mike * - 19 * - CHI * ``` * * Result: * ``` * | Name | Age | City | * |------|-----|------| * | John | 25 | NY | * | Lisa | 32 | LA | * | Mike | 19 | CHI | * ``` * * @param context the current context, injected automatically * @param headers optional list of headers for the columns. If not provided, no headers are used. * @param rows list of rows, where each row is an iterable of cell values. * Rows can have varying lengths; missing cells will be filled with empty content. * @return the generated [Table] node * @wiki Table generation */ @Name("tablebyrows") fun generateTableByRows( @Injected context: Context, headers: List<OutputValue<*>> = emptyList(), rows: List<IterableValue<out OutputValue<*>>>, ): NodeValue { if (rows.isEmpty()) return Table(emptyList()).wrappedAsValue() fun valueToInlineContent(value: OutputValue<*>?): InlineContent = value ?.unwrappedValue ?.let { ValueFactory.inlineMarkdown(it, context).unwrappedValue.children } ?: emptyList() val columnCount = rows.maxOf { it.unwrappedValue.toList().size } val columns = List(columnCount) { val header = headers.getOrNull(it) Table.MutableColumn( alignment = Table.Alignment.NONE, header = Table.Cell(valueToInlineContent(header)), cells = mutableListOf(), ) } for (row in rows) { repeat(columnCount) { i -> val row = row.unwrappedValue.toList().getOrNull(i) columns[i].cells += Table.Cell(valueToInlineContent(row)) } } return Table(columns.map { it.toColumn() }) .wrappedAsValue() } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/Text.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.inline.CodeSpan import com.quarkdown.core.ast.base.inline.LineBreak import com.quarkdown.core.ast.base.inline.Link import com.quarkdown.core.ast.quarkdown.inline.TextTransform import com.quarkdown.core.ast.quarkdown.inline.TextTransformData import com.quarkdown.core.function.library.module.QuarkdownModule import com.quarkdown.core.function.library.module.moduleOf import com.quarkdown.core.function.reflect.annotation.LikelyBody import com.quarkdown.core.function.reflect.annotation.LikelyNamed import com.quarkdown.core.function.reflect.annotation.Name import com.quarkdown.core.function.value.NodeValue import com.quarkdown.core.function.value.data.EvaluableString import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.misc.color.Color import com.quarkdown.core.util.node.toPlainText /** * `Text` stdlib module exporter. * This module handles text formatting. */ val Text: QuarkdownModule = moduleOf( ::text, ::lineBreak, ::code, ::codeSpan, ::loremIpsum, ) /** * Creates an inline text node with specified formatting and transformation. * * @param text inline content to transform * @param size font size, or default if not specified * @param weight font weight, or default if not specified * @param style font style, or default if not specified * @param decoration text decoration, or default if not specified * @param case text case, or default if not specified * @param variant font variant, or default if not specified * @param script vertical script position (`sub` for subscript, `sup` for superscript), or default if not specified * @param color text color, or default if not specified * @param url optional URL to link the text to. If empty (but specified), the URL will match the text content * @param className CSS class name to apply to the element, if supported by the renderer. None if not specified. */ fun text( text: InlineMarkdownContent, @LikelyNamed size: TextTransformData.Size? = null, @LikelyNamed weight: TextTransformData.Weight? = null, @LikelyNamed style: TextTransformData.Style? = null, @LikelyNamed decoration: TextTransformData.Decoration? = null, @LikelyNamed case: TextTransformData.Case? = null, @LikelyNamed variant: TextTransformData.Variant? = null, @LikelyNamed script: TextTransformData.Script? = null, @LikelyNamed color: Color? = null, @LikelyNamed url: String? = null, @Name("classname") className: String? = null, ): NodeValue { val transform = TextTransform( TextTransformData(size, weight, style, decoration, case, variant, script, color), className, text.children, ) return when { // If URL is specified, wrap the text in a link url != null -> { Link( listOf(transform), url = url.takeIf { it.isNotBlank() } ?: text.children.toPlainText(), title = null, ) } else -> { transform } }.wrappedAsValue() } /** * Creates a line break. In standard Markdown, this is also achievable with two spaces at the end of a line, * but this function provides a more explicit and unambiguous way to insert a line break. * @return a [LineBreak] node */ @Name("br") fun lineBreak() = LineBreak.wrappedAsValue() /** * Creates a code block. Contrary to its standard Markdown implementation with backtick/tilde fences, * this function accepts function calls within its [code] argument, * hence it can be used - for example - in combination with [read] to load code from file. * * Example of a code block loaded from file via [read]: * * ``` * .code lang:{kotlin} focus:{2..5} * .read {snippet.kt} * ``` * * @param language optional language of the code * @param caption optional caption * @param showLineNumbers whether to show line numbers * @param focusedLines range of lines to focus on. No lines are focused if unset. Supports open ranges. * Note: HTML rendering requires [showLineNumbers] to be enabled. * @param referenceId optional identifier for cross-referencing this code block elsewhere via [reference] * @param code code content */ fun code( @Name("lang") language: String? = null, @LikelyNamed caption: String? = null, @Name("linenumbers") showLineNumbers: Boolean = true, @Name("focus") focusedLines: Range? = null, @Name("ref") referenceId: String? = null, @LikelyBody code: EvaluableString, ): NodeValue = Code( content = code.content, language = language, showLineNumbers = showLineNumbers, focusedLines = focusedLines, caption = caption, referenceId = referenceId, ).wrappedAsValue() /** * Creates an inline code span. * Equivalent to backticks in standard Markdown, but also accepts function calls within its [text] argument. * @param text code content * @return a [CodeSpan] node */ @Name("codespan") fun codeSpan(text: String) = CodeSpan(text).wrappedAsValue() /** * @return a fixed Lorem Ipsum text. */ @Name("loremipsum") fun loremIpsum() = object {}::class.java .getResourceAsStream("/text/lorem-ipsum.txt")!! .reader() .readText() .wrappedAsValue() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/external/QdLibraryExporter.kt ================================================ package com.quarkdown.stdlib.external import com.quarkdown.core.function.library.Library import com.quarkdown.core.function.library.LibraryExporter import com.quarkdown.stdlib.includeResource import java.io.Reader /** * A [LibraryExporter] that loads a [Library] from a .qd file. * This is destined to be used in other modules (such as `cli`) to load external libraries. * @param name library name * @param reader reader of the .qd file */ class QdLibraryExporter( private val name: String, private val reader: () -> Reader, ) : LibraryExporter { override val library: Library by lazy { Library( name, functions = emptySet(), // The stdlib's includeResource function is used to include the content of the .qd file onLoad = { context -> includeResource(context, reader()) }, ) } } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/Css.kt ================================================ package com.quarkdown.stdlib.internal /** * Matches CSS property declarations: `: value;` or `: value` before `}` */ private val CSS_PROPERTY_VALUE_PATTERN = Regex("""(:\s*)([^;{}:]+?)(\s*)(;|(?=}))""") /** * Applies `!important` to each CSS property value that doesn't already have it. */ internal fun applyImportantToCSS(css: String): String = css.replace(CSS_PROPERTY_VALUE_PATTERN) { match -> val colon = match.groupValues[1] val value = match.groupValues[2].trim() val whitespace = match.groupValues[3] val terminator = match.groupValues[4] if (value.endsWith("!important")) { match.value } else { "$colon$value !important$whitespace$terminator" } } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/FileTree.kt ================================================ package com.quarkdown.stdlib.internal import com.quarkdown.core.ast.NestableNode import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.base.block.list.ListBlock import com.quarkdown.core.ast.base.inline.Strong import com.quarkdown.core.ast.base.inline.StrongEmphasis import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import com.quarkdown.core.util.node.conversion.list.MarkdownListToList import com.quarkdown.core.util.node.toPlainText /** * Text patterns that produce a [FileTreeEntry.Ellipsis] entry, * indicating omitted content in the file tree. * Includes both the raw ASCII `...` and the Unicode ellipsis `…` (U+2026), */ private val ELLIPSIS_TEXTS = setOf("...", "\u2026") /** * Checks whether a node tree contains a [Strong] or [StrongEmphasis] node at any depth, * indicating that the entry should be highlighted. */ private fun Node.isHighlighted(): Boolean = this is Strong || this is StrongEmphasis || (this is NestableNode && children.any { it.isHighlighted() }) /** * Recursively converts a Markdown [ListBlock] into a flat list of [FileTreeEntry] elements. * Inline items become [FileTreeEntry.File]s, nested items become [FileTreeEntry.Directory]s, * and items with `...` as text become [FileTreeEntry.Ellipsis]. * Entries wrapped in strong emphasis are marked as highlighted. */ internal fun fileTreeFromList(list: ListBlock): List<FileTreeEntry> = MarkdownListToList( list, inlineValueMapper = { node -> val text = listOf(node).toPlainText() val highlighted = node.isHighlighted() if (text in ELLIPSIS_TEXTS) { FileTreeEntry.Ellipsis(highlighted) } else { FileTreeEntry.File(text, highlighted) } }, nestedValueMapper = { parent, nestedList -> FileTreeEntry.Directory( listOf(parent).toPlainText(), fileTreeFromList(nestedList), highlighted = parent.isHighlighted(), ) }, ).convert() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/Font.kt ================================================ package com.quarkdown.stdlib.internal import com.quarkdown.core.context.MutableContext import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.core.misc.font.resolver.FontFamilyResolver /** * Resolves a font family by its name or path, and registers it in the media storage if it's not a system font. * @param nameOrPath name, path or URL of the font family to resolve * @param context the context to access the media storage from */ internal fun loadFontFamily( nameOrPath: String, context: MutableContext, ): FontFamily? { val fontFamily = FontFamilyResolver.SYSTEM.resolve(nameOrPath, context.fileSystem.workingDirectory) if (fontFamily is FontFamily.Media) { context.mediaStorage.register(nameOrPath, fontFamily.media) } return fontFamily } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/RootFileSystem.kt ================================================ package com.quarkdown.stdlib.internal import com.quarkdown.core.context.ChildContext import com.quarkdown.core.context.Context import com.quarkdown.core.context.file.FileSystem /** * Types of granularity for determining the root of the file system. */ enum class RootGranularity { /** * The root is the parent directory of the target file being processed by the `quarkdown compile` command */ PROJECT, /** * The root is the parent directory of the current subdocument file. */ SUBDOCUMENT, } /** * Retrieves the relative path to the root of the file system. * The root of the file system is determined by the working directory of the current subdocument. * * Example: * * - When used in the root folder: `.pathtoroot` returns `.` * - When used in `<root>/subfolder`: `.pathtoroot` returns `..` * - When used in `<root>/subfolder1/subfolder2`: `.pathtoroot` returns `../..` * * @return a string value of the relative path to the root of the file system * @throws IllegalStateException if the relative path cannot be determined */ fun getRootFileSystem( context: Context, granularity: RootGranularity = RootGranularity.PROJECT, ): FileSystem? = when (granularity) { RootGranularity.SUBDOCUMENT -> { context.fileSystem.root } RootGranularity.PROJECT -> { val rootContext = (context as? ChildContext<*>)?.root ?: context rootContext.attachedPipeline ?.options ?.workingDirectory ?.let(context.fileSystem::branch) } } ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/Sorting.kt ================================================ package com.quarkdown.stdlib.internal /** * A sorting strategy for sequences of elements. * @param T the type of elements to sort */ interface Sorting<T> { /** * Sorts a sequence of elements according to this strategy. */ val sort: (Sequence<T>, Ordering) -> Sequence<T> } /** * The order in which elements are sorted. */ enum class Ordering { /** Elements are sorted in ascending order. */ ASCENDING, /** Elements are sorted in descending order. */ DESCENDING, } /** * Sorts elements by a [selector] value in the given [ordering]. * * `null` values are always sorted first, regardless of the ordering. * * @param ordering whether to sort in ascending or descending order * @param comparator optional additional comparator to use for comparing the selected values * @param selector function that extracts a comparable value from each element * @return a new sequence with elements sorted according to the selector and ordering */ fun <T, R : Comparable<R>> Sequence<T>.sortedBy( ordering: Ordering, comparator: Comparator<in R>? = null, selector: (T) -> R?, ): Sequence<T> { val nullSafeComparator = comparator?.let(::nullsFirst) ?: nullsFirst() val finalComparator = when (ordering) { Ordering.ASCENDING -> compareBy(nullSafeComparator, selector) Ordering.DESCENDING -> compareByDescending(nullSafeComparator, selector) } return this.sortedWith(finalComparator) } /** * A comparator that sorts alphanumeric strings in a human-friendly way. * For example, `$120` comes after `$30`, as opposed to the usual lexicographical order. * * Wrapped around the `alphanumeric-comparator` library. */ object AlphanumericComparator : Comparator<CharSequence> by se.sawano.java.text .AlphanumericComparator() ================================================ FILE: quarkdown-stdlib/src/main/kotlin/com/quarkdown/stdlib/internal/Types.kt ================================================ package com.quarkdown.stdlib.internal import com.quarkdown.core.function.value.Value // Types utilities for the stdlib. /** * Converts [Value] to a [String]. */ internal fun Value<*>.asString(): String = when (val value = unwrappedValue) { is Value<*> -> value.asString() else -> value.toString() } /** * Converts [Value] to a [Double]. * @return the value as a double, or 0 if the value is not numeric */ internal fun Value<*>.asDouble(): Double = when (val value = unwrappedValue) { is Number -> value.toDouble() else -> value.toString().toDoubleOrNull() } ?: .0 ================================================ FILE: quarkdown-stdlib/src/main/resources/lib/localization.qd ================================================ <!-- stdlib localization table --> .localization name:{std} - English - note: Note - tip: Tip - warning: Warning - important: Important - error: Error - todo: To do - tableofcontents: Table of Contents - tableofcontents/docs: On this page - bibliography: References - figure: Figure - table: Table - listing: Listing - section: Section - Italian - note: Nota - tip: Consiglio - warning: Attenzione - important: Importante - error: Errore - todo: Da fare - tableofcontents: Indice - tableofcontents/docs: In questa pagina - bibliography: Riferimenti - figure: Figura - table: Tabella - listing: Listato - section: Sezione - German - note: Hinweis - tip: Tip - warning: Warnung - important: Wichtig - error: Fehler - todo: Aufgabe - tableofcontents: Inhaltsverzeichnis - tableofcontents/docs: Auf dieser Seite - bibliography: Literaturverzeichnis - figure: Abbildung - table: Tabelle - listing: Listing - section: Abschnitt - Japanese - note: ノート - tip: ヒント - warning: 警告 - important: 重要 - error: エラー - todo: タスク - tableofcontents: 目次 - tableofcontents/docs: このページの内容 - bibliography: 参考文献 - figure: 図 - table: テーブル - listing: リスト - section: セクション - Chinese - note: 注意 - tip: 提示 - warning: 警告 - important: 重要 - error: 错误 - todo: 待办 - tableofcontents: 目录 - tableofcontents/docs: 本页目录 - bibliography: 参考文献 - figure: 图 - table: 表 - listing: 代码 - section: 章节 - French - note: Note - tip: Astuce - warning: Attention - important: Important - error: Erreur - todo: À faire - tableofcontents: Table des matières - tableofcontents/docs: Sur cette page - biblioraphy: Sources - figure: Figure - table: Tableau - listing: Listing - section: Section ================================================ FILE: quarkdown-stdlib/src/main/resources/text/lorem-ipsum.txt ================================================ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam aliquet ut erat nec suscipit. Mauris vitae massa eu leo molestie ullamcorper. Fusce ornare neque quis faucibus laoreet. Pellentesque mauris sapien, pretium sed leo vitae, aliquam suscipit dolor. Aenean egestas congue rutrum. Nunc eget eros eu justo fringilla lobortis efficitur non est. In ultrices lectus ac iaculis cursus. Phasellus at luctus nibh, non porttitor ex. Vestibulum ligula metus, dignissim ac nisi non, tristique hendrerit purus. ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/DataTest.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.assertNodeEquals import com.quarkdown.core.ast.base.block.Table import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.ast.dsl.buildInline import com.quarkdown.core.attachMockPipeline import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.SharedContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.util.node.toPlainText import com.quarkdown.stdlib.internal.Ordering import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertIs import kotlin.test.assertTrue private const val DATA_FOLDER = "src/test/resources/data" private const val LIST_FILES_FOLDER = "listfiles" /** * [Data] module tests. */ class DataTest { private val context = MutableContext(QuarkdownFlavor) @BeforeTest fun setup() { // Attach a mock pipeline to the context, in order to set a working directory for the function calls to use. val options = PipelineOptions(workingDirectory = File(DATA_FOLDER)) context.attachMockPipeline(options) } @Test fun `file contents`() { val path = "test.txt" assertEquals( "Line 1\nLine 2\n\nLine 4\nLine 5", read(context, path).unwrappedValue, ) assertEquals( "Line 2\n\nLine 4", read(context, path, Range(2, 4)).unwrappedValue, ) assertEquals( "Line 1\nLine 2", read(context, path, Range(null, 2)).unwrappedValue, ) assertEquals( "Line 4\nLine 5", read(context, path, Range(4, null)).unwrappedValue, ) // Out of bounds ranges. assertFails { read(context, path, Range(1, 8)) } assertFails { read(context, path, Range(0, 3)) } assertFails { read(context, path, Range(null, 9)) } assertFails { read(context, path, Range(9, null)) } } @Test fun `path to root, root`() { val relativePath = pathToRoot(context).unwrappedValue assertEquals(".", relativePath) } @Test fun `path to root, nested`() { val nested = File(DATA_FOLDER, "nested") .resolve("a") .resolve("b") val branchedOutFileSystem = context.fileSystem.branch(workingDirectory = nested) val branchedOutContext = SharedContext(context, branchedOutFileSystem) val relativePath = pathToRoot(branchedOutContext).unwrappedValue assertEquals("..${File.separator}..${File.separator}..", relativePath) } @Test fun `csv table`() { val path = "people.csv" val table = csv(context, path) assertIs<Table>(table.unwrappedValue) val columns = (table.unwrappedValue as Table).columns.iterator() with(columns.next()) { assertEquals("Name", (header.text.first() as Text).text) with(cells.iterator()) { assertEquals("Alex", next().text.toPlainText()) assertEquals("Bert", next().text.toPlainText()) assertEquals("Carl", next().text.toPlainText()) assertEquals("Dave", next().text.toPlainText()) assertEquals("Elly", next().text.toPlainText()) assertEquals("Fran", next().text.toPlainText()) assertEquals("Gwen", next().text.toPlainText()) assertEquals("Hank", next().text.toPlainText()) assertEquals("Ivan", next().text.toPlainText()) } } with(columns.next()) { assertEquals("Sex", header.text.toPlainText()) with(cells.iterator()) { repeat(4) { assertEquals("M", next().text.toPlainText()) } repeat(3) { assertEquals("F", next().text.toPlainText()) } repeat(2) { assertEquals("M", next().text.toPlainText()) } } } with(columns.next()) { assertEquals("Age", header.text.toPlainText()) with(cells.iterator()) { assertEquals("41", next().text.toPlainText()) assertEquals("42", next().text.toPlainText()) assertEquals("32", next().text.toPlainText()) assertEquals("39", next().text.toPlainText()) assertEquals("30", next().text.toPlainText()) assertEquals("33", next().text.toPlainText()) assertEquals("26", next().text.toPlainText()) assertEquals("30", next().text.toPlainText()) assertEquals("53", next().text.toPlainText()) } } with(columns.next()) { assertEquals("Height (in)", header.text.toPlainText()) with(cells.iterator()) { assertEquals("74", next().text.toPlainText()) assertEquals("68", next().text.toPlainText()) assertEquals("70", next().text.toPlainText()) assertEquals("72", next().text.toPlainText()) assertEquals("66", next().text.toPlainText()) assertEquals("66", next().text.toPlainText()) assertEquals("64", next().text.toPlainText()) assertEquals("71", next().text.toPlainText()) assertEquals("72", next().text.toPlainText()) } } with(columns.next()) { assertEquals("Weight (lbs)", header.text.toPlainText()) with(cells.iterator()) { assertEquals("170", next().text.toPlainText()) assertEquals("166", next().text.toPlainText()) assertEquals("155", next().text.toPlainText()) assertEquals("167", next().text.toPlainText()) assertEquals("124", next().text.toPlainText()) assertEquals("115", next().text.toPlainText()) assertEquals("121", next().text.toPlainText()) assertEquals("158", next().text.toPlainText()) assertEquals("175", next().text.toPlainText()) } } } @Test fun `csv table, as plain text cells`() { val path = "drinks.csv" val table = csv(context, path, mode = CsvParsingMode.PLAIN).unwrappedValue assertIs<Table>(table) val columns = table.columns.iterator() with(columns.next()) { assertEquals("Name", (header.text.first() as Text).text) with(cells.iterator()) { assertEquals("Alice", (next().text.first() as Text).text) assertEquals("Bob", (next().text.first() as Text).text) } } with(columns.next()) { assertEquals("*Favorite* drink", (header.text.first() as Text).text) with(cells.iterator()) { assertEquals("**Coffee**", (next().text.first() as Text).text) assertEquals("***Pepsi***", (next().text.first() as Text).text) } } } @Test fun `csv table, as markdown cells`() { val path = "drinks.csv" val table = csv(context, path, mode = CsvParsingMode.MARKDOWN).unwrappedValue assertIs<Table>(table) val columns = table.columns.iterator() with(columns.next()) { assertEquals("Name", (header.text.first() as Text).text) with(cells.iterator()) { assertEquals("Alice", (next().text.first() as Text).text) assertEquals("Bob", (next().text.first() as Text).text) } } with(columns.next()) { assertNodeEquals( buildInline { emphasis { text("Favorite") } text(" drink") }, header.text, ) with(cells.iterator()) { assertNodeEquals(buildInline { strong { text("Coffee") } }, next().text) assertNodeEquals(buildInline { strongEmphasis { text("Pepsi") } }, next().text) } } } @Test fun `list files unsorted`() { val files = listFiles(context, LIST_FILES_FOLDER, fullPath = false) val names = files.unwrappedValue.map { it.unwrappedValue }.toSet() assertEquals(setOf("a.txt", "b.txt", "c.txt", "d"), names) } @Test fun `list files sorted by name ascending`() { val files = listFiles( context, LIST_FILES_FOLDER, fullPath = false, sortBy = FileSorting.NAME, ) val names = files.unwrappedValue.map { it.unwrappedValue }.toList() assertEquals(listOf("a.txt", "b.txt", "c.txt", "d"), names) } @Test fun `list files sorted by name descending`() { val files = listFiles( context, LIST_FILES_FOLDER, fullPath = false, sortBy = FileSorting.NAME, order = Ordering.DESCENDING, ) val names = files.unwrappedValue.map { it.unwrappedValue }.toList() assertEquals(listOf("d", "c.txt", "b.txt", "a.txt"), names) } @Test fun `list non-directory files`() { val files = listFiles( context, LIST_FILES_FOLDER, listDirectories = false, fullPath = false, sortBy = FileSorting.NAME, ) val names = files.unwrappedValue.map { it.unwrappedValue }.toList() assertEquals(listOf("a.txt", "b.txt", "c.txt"), names) } @Test fun `list files with full path`() { val files = listFiles(context, LIST_FILES_FOLDER, fullPath = true, sortBy = FileSorting.NAME) val paths = files.unwrappedValue.map { it.unwrappedValue }.toList() paths.forEach { path -> assertContains(path, LIST_FILES_FOLDER) assertTrue( path.endsWith("a.txt") || path.endsWith("b.txt") || path.endsWith("c.txt") || path.endsWith("d"), ) assertTrue(File(path).isAbsolute) } } @Test fun `list files non-existent directory`() { assertFails { listFiles(context, "nonexistent") } } @Test fun `list files on a file instead of directory`() { assertFails { listFiles(context, "test.txt") } } @Test fun `get file name with extension`() { val name = fileName(context, "listfiles/a.txt", includeExtension = true) assertEquals("a.txt", name.unwrappedValue) } @Test fun `get file name without extension`() { val name = fileName(context, "listfiles/a.txt", includeExtension = false) assertEquals("a", name.unwrappedValue) } @Test fun `get file name non-existent file`() { assertFails { fileName(context, "listfiles/nonexistent.txt") } } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/FileTreeTest.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.Node import com.quarkdown.core.ast.dsl.buildBlocks import com.quarkdown.core.ast.quarkdown.block.FileTree import com.quarkdown.core.ast.quarkdown.block.FileTreeEntry import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [fileTree] generation from Markdown lists. */ class FileTreeTest { private fun buildFileTree(blocks: List<Node>) = fileTree(MarkdownContent(blocks)).unwrappedValue as FileTree @Test fun `only top-level files`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { text("file2.json") } } listItem { paragraph { text("file3.csv") } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.json"), FileTreeEntry.File("file3.csv"), ), tree.entries, ) } @Test fun `single directory`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("dir1") } unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { text("file2.json") } } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.Directory( "dir1", listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.json"), ), ), ), tree.entries, ) } @Test fun `nested directories`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("src") } unorderedList(loose = false) { listItem { paragraph { text("components") } unorderedList(loose = false) { listItem { paragraph { text("Button.ts") } } listItem { paragraph { text("Card.ts") } } } } listItem { paragraph { text("index.ts") } } } } listItem { paragraph { text("README.md") } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.Directory( "src", listOf( FileTreeEntry.Directory( "components", listOf( FileTreeEntry.File("Button.ts"), FileTreeEntry.File("Card.ts"), ), ), FileTreeEntry.File("index.ts"), ), ), FileTreeEntry.File("README.md"), ), tree.entries, ) } @Test fun `top-level ellipsis`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { text("...") } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.Ellipsis(), ), tree.entries, ) } @Test fun `ellipsis inside directory`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("src") } unorderedList(loose = false) { listItem { paragraph { text("main.ts") } } listItem { paragraph { text("...") } } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts"), FileTreeEntry.Ellipsis(), ), ), ), tree.entries, ) } @Test fun `highlighted file`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { strong { text("file2.txt") } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.txt", highlighted = true), ), tree.entries, ) } @Test fun `highlighted directory`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { strong { text("src") } } unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { text("file2.txt") } } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.File("file2.txt"), ), highlighted = true, ), ), tree.entries, ) } @Test fun `highlighted ellipsis`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { text("file1.txt") } } listItem { paragraph { strong { text("...") } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.File("file1.txt"), FileTreeEntry.Ellipsis(highlighted = true), ), tree.entries, ) } @Test fun `multiple highlighted entries`() { val tree = buildBlocks { unorderedList(loose = false) { listItem { paragraph { strong { text("file1.txt") } } } listItem { paragraph { strong { text("src") } } unorderedList(loose = false) { listItem { paragraph { strong { text("main.ts") } } } listItem { paragraph { text("utils.ts") } } } } } }.let(::buildFileTree) assertEquals( listOf( FileTreeEntry.File("file1.txt", highlighted = true), FileTreeEntry.Directory( "src", listOf( FileTreeEntry.File("main.ts", highlighted = true), FileTreeEntry.File("utils.ts"), ), highlighted = true, ), ), tree.entries, ) } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/FlowTest.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.InlineMarkdownContent import com.quarkdown.core.ast.MarkdownContent import com.quarkdown.core.ast.base.block.list.ListItem import com.quarkdown.core.ast.base.block.list.UnorderedList import com.quarkdown.core.attachMockPipeline import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.call.FunctionCall import com.quarkdown.core.function.call.FunctionCallArgument import com.quarkdown.core.function.error.InvalidLambdaArgumentCountException import com.quarkdown.core.function.value.DynamicValue import com.quarkdown.core.function.value.OutputValue import com.quarkdown.core.function.value.StringValue import com.quarkdown.core.function.value.VoidValue import com.quarkdown.core.function.value.data.Lambda import com.quarkdown.core.function.value.data.LambdaParameter import com.quarkdown.core.function.value.data.Range import com.quarkdown.core.function.value.factory.ValueFactory import com.quarkdown.core.function.value.output.node.BlockNodeOutputValueVisitor import com.quarkdown.core.function.value.output.node.InlineNodeOutputValueVisitor import com.quarkdown.core.function.value.wrappedAsValue import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull /** * [Flow] module tests. */ class FlowTest { private val context = MutableContext(QuarkdownFlavor) @BeforeTest fun setup() { context.attachMockPipeline() } private fun call( functionName: String, arguments: List<FunctionCallArgument>, ): OutputValue<*> { with(context.getFunctionByName(functionName)) { assertNotNull(this) assertEquals(functionName, name) assertEquals(arguments.size, parameters.size) val call = FunctionCall(this, arguments, context) return call.execute() } } @Test fun `custom function`() { function( context, name = "myfunc1", body = Lambda(context, explicitParameters = emptyList()) { _, _ -> "Hello Quarkdown".wrappedAsValue() }, ) assertEquals( StringValue("Hello Quarkdown"), call("myfunc1", arguments = emptyList()), ) function( context, name = "myfunc2", body = ValueFactory.lambda("- Hello **Quarkdown**\n- Hello", context).unwrappedValue, ) call("myfunc2", arguments = emptyList()).let { assertIs<DynamicValue>(it) assertEquals("- Hello **Quarkdown**\n- Hello", it.unwrappedValue) // Block node conversion val blockNode = BlockNodeOutputValueVisitor(context).visit(it) assertIs<MarkdownContent>(blockNode) assertEquals(1, blockNode.children.size) // Inline node conversion val inlineNode = InlineNodeOutputValueVisitor(context).visit(it) assertIs<InlineMarkdownContent>(inlineNode) assertEquals(3, inlineNode.children.size) val list = blockNode.children.first() assertIs<UnorderedList>(list) assertEquals(2, list.children.size) assertIs<ListItem>(list.children[0]) assertIs<ListItem>(list.children[1]) } function( context, name = "myfunc3", body = ValueFactory.lambda("to from: Hello **.to** from _.from_", context).unwrappedValue, ) assertEquals( DynamicValue("Hello **Quarkdown** from _iamgio_"), call( "myfunc3", arguments = listOf( FunctionCallArgument(DynamicValue("Quarkdown")), FunctionCallArgument(DynamicValue("iamgio")), ), ), ) } @Test fun `control flow`() { val control1 = `if`( isLower(2, 4).unwrappedValue, Lambda(context, explicitParameters = emptyList()) { _, _ -> "Hello Quarkdown".wrappedAsValue() }, ) assertEquals("Hello Quarkdown", control1.unwrappedValue) val control2 = `if`( isGreater(2, 4).unwrappedValue, Lambda(context, explicitParameters = emptyList()) { _, _ -> "Hello Quarkdown".wrappedAsValue() }, ) assertEquals(VoidValue, control2) val control3 = ifNot( isGreater(2, 4).unwrappedValue, Lambda(context, explicitParameters = emptyList()) { _, _ -> "Hello Quarkdown".wrappedAsValue() }, ) assertEquals("Hello Quarkdown", control3.unwrappedValue) assertFailsWith<InvalidLambdaArgumentCountException> { `if`( isLower(2, 4).unwrappedValue, Lambda(context, explicitParameters = listOf(LambdaParameter("a"))) { _, _ -> "Hello Quarkdown".wrappedAsValue() }, ) } } @Test fun `loop flow`() { val loop1 = forEach( listOf( StringValue("Hello"), StringValue("Quarkdown"), ), body = Lambda(context, explicitParameters = emptyList()) { args, _ -> "**${args.first().unwrappedValue}**".wrappedAsValue() }, ) assertEquals( listOf( StringValue("**Hello**"), StringValue("**Quarkdown**"), ), loop1.unwrappedValue, ) val loop2 = forEach( Range(start = 2, end = 4), // Explicit lambda placeholder body = ValueFactory.lambda("n: \nN: .n", context).unwrappedValue, ) assertEquals( listOf( DynamicValue("N: 2"), DynamicValue("N: 3"), DynamicValue("N: 4"), ), loop2.unwrappedValue, ) val loop3 = forEach( ValueFactory.range("..4").unwrappedValue, body = ValueFactory.lambda("N\\: .1", context).unwrappedValue, ) assertEquals( listOf( DynamicValue("N: 1"), DynamicValue("N: 2"), DynamicValue("N: 3"), DynamicValue("N: 4"), ), loop3.unwrappedValue, ) // Iterating ranges with indefinite right end is not allowed. assertFails { forEach( ValueFactory.range("1..").unwrappedValue, body = ValueFactory.lambda("N\\: .1", context).unwrappedValue, ) } } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/LocalizationTest.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.context.MutableContext import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.function.value.DictionaryValue import com.quarkdown.core.function.value.wrappedAsValue import com.quarkdown.core.localization.LocaleLoader import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals /** * [Localization] module tests. */ class LocalizationTest { private val context = MutableContext(QuarkdownFlavor) @BeforeTest fun setup() { context.documentInfo = context.documentInfo.copy(locale = LocaleLoader.SYSTEM.fromName("English")!!) } @Test fun `localization table`() { localization( context, "mytable", contents = mapOf( "English" to DictionaryValue( mutableMapOf( "morning" to "Good morning".wrappedAsValue(), "evening" to "Good evening".wrappedAsValue(), ), ), "Italian" to DictionaryValue( mutableMapOf( "morning" to "Buongiorno".wrappedAsValue(), "evening" to "Buonasera".wrappedAsValue(), ), ), ), ) assertEquals(1, context.localizationTables.size) val table = context.localizationTables["mytable"]!! assertEquals("Good morning", table[LocaleLoader.SYSTEM.fromName("English")!!]!!["morning"]) assertEquals("Buongiorno", table[LocaleLoader.SYSTEM.fromName("Italian")!!]!!["morning"]) assertEquals("Good evening", context.localize("mytable", "evening")) } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/TextTest.kt ================================================ package com.quarkdown.stdlib import com.quarkdown.core.ast.base.block.Code import com.quarkdown.core.ast.base.inline.Text import com.quarkdown.core.function.value.data.EvaluableString import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs /** * [Text] module tests. */ class TextTest { @Test fun `markdown code`() { val code = code( language = "kotlin", showLineNumbers = false, code = EvaluableString("fun foo() = 1"), ) val node = code.unwrappedValue assertIs<Code>(node) assertEquals("fun foo() = 1", node.content) assertEquals("kotlin", node.language) assertFalse(node.showLineNumbers) } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/internal/CssTest.kt ================================================ package com.quarkdown.stdlib.internal import kotlin.test.Test import kotlin.test.assertEquals class CssTest { @Test fun `single property`() { assertEquals( "body { color: red !important; }", applyImportantToCSS("body { color: red; }"), ) } @Test fun `multiple properties`() { assertEquals( "body { color: red !important; background: blue !important; }", applyImportantToCSS("body { color: red; background: blue; }"), ) } @Test fun `already has important`() { assertEquals( "body { color: red !important; }", applyImportantToCSS("body { color: red !important; }"), ) } @Test fun `mixed with and without important`() { assertEquals( "body { color: red !important; background: blue !important; }", applyImportantToCSS("body { color: red !important; background: blue; }"), ) } @Test fun `multiline css`() { val input = """ body { color: red; background: blue; } """.trimIndent() val expected = """ body { color: red !important; background: blue !important; } """.trimIndent() assertEquals(expected, applyImportantToCSS(input)) } @Test fun `multiple selectors`() { val input = "h1 { font-size: 2em; } p { margin: 0; }" val expected = "h1 { font-size: 2em !important; } p { margin: 0 !important; }" assertEquals(expected, applyImportantToCSS(input)) } @Test fun `complex values`() { assertEquals( "body { font-family: Arial, sans-serif !important; }", applyImportantToCSS("body { font-family: Arial, sans-serif; }"), ) } @Test fun `css variables`() { assertEquals( ":root { --qd-color: red !important; }", applyImportantToCSS(":root { --qd-color: red; }"), ) } @Test fun `value with url`() { assertEquals( "body { background: url('image.png') !important; }", applyImportantToCSS("body { background: url('image.png'); }"), ) } @Test fun `multiline without trailing semicolon`() { val input = """ body { color: red; background: blue } """.trimIndent() val expected = """ body { color: red !important; background: blue !important } """.trimIndent() assertEquals(expected, applyImportantToCSS(input)) } } ================================================ FILE: quarkdown-stdlib/src/test/kotlin/com/quarkdown/stdlib/internal/SortingTest.kt ================================================ package com.quarkdown.stdlib.internal import kotlin.test.Test import kotlin.test.assertEquals /** * [Sorting] and [Ordering] tests. */ class SortingTest { @Test fun `sortedBy ascending`() { val sequence = sequenceOf(3, 1, 4, 1, 5, 9, 2, 6) val sorted = sequence.sortedBy(Ordering.ASCENDING) { it } assertEquals(listOf(1, 1, 2, 3, 4, 5, 6, 9), sorted.toList()) } @Test fun `sortedBy descending`() { val sequence = sequenceOf(3, 1, 4, 1, 5, 9, 2, 6) val sorted = sequence.sortedBy(Ordering.DESCENDING) { it } assertEquals(listOf(9, 6, 5, 4, 3, 2, 1, 1), sorted.toList()) } @Test fun `sortedBy with selector ascending`() { val sequence = sequenceOf("apple", "pie", "a", "banana") val sorted = sequence.sortedBy(Ordering.ASCENDING) { it.length } assertEquals(listOf("a", "pie", "apple", "banana"), sorted.toList()) } @Test fun `sortedBy with selector descending`() { val sequence = sequenceOf("apple", "pie", "a", "banana") val sorted = sequence.sortedBy(Ordering.DESCENDING) { it.length } assertEquals(listOf("banana", "apple", "pie", "a"), sorted.toList()) } @Test fun `sortedBy with null values`() { val sequence = sequenceOf("apple", "pie", null, "banana") val sorted = sequence.sortedBy(Ordering.ASCENDING) { it?.length } // Nulls come first when sorting ascending assertEquals(listOf(null, "pie", "apple", "banana"), sorted.toList()) } @Test fun `sortedBy empty sequence`() { val sequence = emptySequence<Int>() val sortedAsc = sequence.sortedBy(Ordering.ASCENDING) { it } val sortedDesc = sequence.sortedBy(Ordering.DESCENDING) { it } assertEquals(emptyList(), sortedAsc.toList()) assertEquals(emptyList(), sortedDesc.toList()) } @Test fun `sortedBy with AlphanumericComparator`() { val sequence = sequenceOf("$120", "$30", "$5", "$1000") val sorted = sequence.sortedBy(Ordering.ASCENDING, AlphanumericComparator) { it } assertEquals(listOf("$5", "$30", "$120", "$1000"), sorted.toList()) } @Test fun `sortedBy with AlphanumericComparator descending`() { val sequence = sequenceOf("item2", "item10", "item1", "item20") val sorted = sequence.sortedBy(Ordering.DESCENDING, AlphanumericComparator) { it } assertEquals(listOf("item20", "item10", "item2", "item1"), sorted.toList()) } @Test fun `sorting interface implementation`() { val stringSorting = object : Sorting<String> { override val sort: (Sequence<String>, Ordering) -> Sequence<String> = { seq, ordering -> seq.sortedBy(ordering) { it.length } } } val sequence = sequenceOf("apple", "pie", "a", "banana") assertEquals( listOf("a", "pie", "apple", "banana"), stringSorting.sort(sequence, Ordering.ASCENDING).toList(), ) assertEquals( listOf("banana", "apple", "pie", "a"), stringSorting.sort(sequence, Ordering.DESCENDING).toList(), ) } } ================================================ FILE: quarkdown-stdlib/src/test/resources/data/code.html ================================================ <html lang="en"> <head> <title>Title</title> </head> <body> body text. </body> </html> ================================================ FILE: quarkdown-stdlib/src/test/resources/data/drinks.csv ================================================ Name, *Favorite* drink Alice, **Coffee** Bob, ***Pepsi*** ================================================ FILE: quarkdown-stdlib/src/test/resources/data/listfiles/a.txt ================================================ content a ================================================ FILE: quarkdown-stdlib/src/test/resources/data/listfiles/b.txt ================================================ content b ================================================ FILE: quarkdown-stdlib/src/test/resources/data/listfiles/c.txt ================================================ content c ================================================ FILE: quarkdown-stdlib/src/test/resources/data/listfiles/d/d.txt ================================================ ================================================ FILE: quarkdown-stdlib/src/test/resources/data/people.csv ================================================ Name, Sex, Age, Height (in), Weight (lbs) Alex, M, 41, 74, 170 Bert, M, 42, 68, 166 Carl, M, 32, 70, 155 Dave, M, 39, 72, 167 Elly, F, 30, 66, 124 Fran, F, 33, 66, 115 Gwen, F, 26, 64, 121 Hank, M, 30, 71, 158 Ivan, M, 53, 72, 175 ================================================ FILE: quarkdown-stdlib/src/test/resources/data/test.txt ================================================ Line 1 Line 2 Line 4 Line 5 ================================================ FILE: quarkdown-test/README.md ================================================ # test This module contains unit tests that take actual Quarkdown sources as inputs, making sure they produce the expected final output by correctly executing each step of the compilation pipeline, from lexing to rendering. The programs make use of the [stdlib](../stdlib), and libraries from [libs](../libs) can be also included. ================================================ FILE: quarkdown-test/build.gradle.kts ================================================ extra["noRuntime"] = true plugins { kotlin("jvm") } dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") implementation(project(":quarkdown-core")) implementation(project(":quarkdown-html")) implementation(project(":quarkdown-plaintext")) implementation(project(":quarkdown-stdlib")) } tasks.test { useJUnitPlatform() } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/BibliographyTest.kt ================================================ package com.quarkdown.test import com.quarkdown.rendering.plaintext.extension.plainText import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue private const val BIBLIOGRAPHY_CALL = ".bibliography {bib/bibliography.bib} breakpage:{no}" /** * Builds the expected IEEE bibliography HTML output. * @param availableLabel the localized label for online availability (varies by locale) */ private fun ieeeBibliographyOutput(availableLabel: String = "Available:") = "<div class=\"bibliography bibliography-ieee\">" + "<span class=\"bibliography-entry-label\">[1]</span>" + "<span class=\"bibliography-entry-content\">" + "A. Einstein, \u201CZur Elektrodynamik bewegter K\u00F6rper. (German) " + "[On the electrodynamics of moving bodies],\u201D " + "<em>Annalen der Physik</em>" + ", vol. 322, Art. no. 10, 1905, doi: " + "<a href=\"http://dx.doi.org/10.1002/andp.19053221004\">" + "http://dx.doi.org/10.1002/andp.19053221004" + "</a>." + "</span>" + "<span class=\"bibliography-entry-label\">[2]</span>" + "<span class=\"bibliography-entry-content\">" + "M. Goossens, F. Mittelbach, and A. Samarin, " + "<em>The LaTeX Companion</em>" + ". Reading, Massachusetts: Addison-Wesley, 1993." + "</span>" + "<span class=\"bibliography-entry-label\">[3]</span>" + "<span class=\"bibliography-entry-content\">" + "D. Knuth, \u201CKnuth: Computers and Typesetting.\u201D [Online]. $availableLabel " + "<a href=\"http://www-cs-faculty.stanford.edu/uno/abcde.html\">" + "http://www-cs-faculty.stanford.edu/uno/abcde.html" + "</a>" + "</span>" + "</div>" /** * Tests for bibliographies and citations. */ class BibliographyTest { @Test fun `bibliography from bib file`() { execute(BIBLIOGRAPHY_CALL) { assertEquals( ieeeBibliographyOutput(), it, ) } } @Test fun `localized bibliography`() { execute(".doclang {en}\n$BIBLIOGRAPHY_CALL") { assertEquals( "<h1 data-decorative=\"\">" + "References" + "</h1>" + ieeeBibliographyOutput(availableLabel = "Available at:"), it, ) } } @Test fun `custom bibliography title`() { execute("$BIBLIOGRAPHY_CALL title:{My bibliography}") { assertEquals( "<h1 data-decorative=\"\">" + "My bibliography" + "</h1>" + ieeeBibliographyOutput(), it, ) } } @Test fun citation() { execute( """ abc .cite {einstein} def .cite {latexcompanion} ghi .cite {knuthwebsite} $BIBLIOGRAPHY_CALL abc .cite {einstein} def .cite {latexcompanion} ghi .cite {knuthwebsite} """.trimIndent(), ) { assertEquals( "<p>abc [1] def [2] ghi [3]</p>" + ieeeBibliographyOutput() + "<p>abc [1] def [2] ghi [3]</p>", it, ) } } @Test fun `citation (plaintext)`() { execute( """ abc .cite {einstein} def .cite {latexcompanion} ghi .cite {knuthwebsite} $BIBLIOGRAPHY_CALL abc .cite {einstein} def .cite {latexcompanion} ghi .cite {knuthwebsite} """.trimIndent(), renderer = { rendererFactory, ctx -> rendererFactory.plainText(ctx) }, ) { assertEquals( "abc [1] def [2] ghi [3]\n\n" + "[1] A. Einstein, \u201CZur Elektrodynamik bewegter K\u00F6rper. (German) " + "[On the electrodynamics of moving bodies],\u201D Annalen der Physik, " + "vol. 322, Art. no. 10, 1905, doi: http://dx.doi.org/10.1002/andp.19053221004.\n" + "[2] M. Goossens, F. Mittelbach, and A. Samarin, The LaTeX Companion. " + "Reading, Massachusetts: Addison-Wesley, 1993.\n" + "[3] D. Knuth, \u201CKnuth: Computers and Typesetting.\u201D [Online]. Available: " + "http://www-cs-faculty.stanford.edu/uno/abcde.html\n\n" + "abc [1] def [2] ghi [3]\n\n", it, ) } } @Test fun `bibliography custom heading depth`() { execute(".doclang {en}\n$BIBLIOGRAPHY_CALL headingdepth:{3}") { assertEquals( "<h3 data-decorative=\"\">" + "References" + "</h3>" + ieeeBibliographyOutput(availableLabel = "Available at:"), it, ) } } @Test fun `bibliography heading indexed in toc, unnumbered`() { execute( """ .doclang {en} .noautopagebreak .tableofcontents title:{} .bibliography {bib/bibliography.bib} indexheading:{yes} """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertTrue( it.contains( "<li data-target-id=\"references\" data-depth=\"1\">" + "<a href=\"#references\">References</a></li>", ), ) } } @Test fun `bibliography heading indexed in toc, numbered`() { execute( """ .doclang {en} .numbering - headings: 1.A.a .noautopagebreak .tableofcontents title:{} .bibliography {bib/bibliography.bib} indexheading:{yes} numberheading:{yes} """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true, enableLocationAwareness = true), ) { assertTrue( it.contains( "<li data-target-id=\"references\" data-depth=\"1\" data-location=\"1\">" + "<a href=\"#references\">References</a></li>", ), ) } } @Test fun `multi-key citation`() { execute( """ abc .cite {einstein, latexcompanion} def $BIBLIOGRAPHY_CALL """.trimIndent(), ) { assertEquals( "<p>abc [1], [2] def</p>" + ieeeBibliographyOutput(), it, ) } } @Test fun `multi-key citation (apa)`() { execute( """ abc .cite {einstein, latexcompanion} def .bibliography {bib/bibliography.bib} style:{apa} breakpage:{no} """.trimIndent(), ) { // APA uses "et al." for works with 3+ authors. val citation = it.toString().substringAfter("abc ").substringBefore(" def") assertEquals( "(Einstein, 1905; Goossens et al., 1993)", citation, ) } } @Test fun `unresolved citation`() { execute( "abc .cite {abc}\n\n" + BIBLIOGRAPHY_CALL, ) { assertEquals( "<p>abc [???]</p>" + ieeeBibliographyOutput(), it, ) } } @Test fun `partially unresolved multi-key citation`() { execute( "abc .cite {einstein, invalidkey}\n\n" + BIBLIOGRAPHY_CALL, ) { assertEquals( "<p>abc [???]</p>" + ieeeBibliographyOutput(), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/BoxesTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [com.quarkdown.core.ast.quarkdown.block.Box] nodes. */ class BoxesTest { private fun box( type: String, title: String?, content: String, ) = buildString { append("<div class=\"box $type\">") if (title != null) { append("<header><h4>$title</h4></header>") } append("<div class=\"box-content\"><p>") append(content) append("</p></div></div>") } @Test fun `titled callout`() { execute(".box {Hello}\n\tHello, **world**!") { assertEquals( box("callout", "Hello", "Hello, <strong>world</strong>!"), it, ) } } @Test fun `titled tip`() { execute(".box {Hello} type:{tip}\n\tHello, world!") { assertEquals( box("tip", "Hello", "Hello, world!"), it, ) } } @Test fun `titled warning`() { execute(".box {Hello, *world*} type:{warning}\n\tHello, world!") { assertEquals( box("warning", "Hello, <em>world</em>", "Hello, world!"), it, ) } } @Test fun error() { execute(".box type:{error}\n\tHello, world!") { assertEquals( box("error", null, "Hello, world!"), it, ) } } @Test fun `localized title`() { execute( """ .doclang {english} .box type:{error} Hello, world! .box type:{tip} Hello, world! .box Hello, world! """.trimIndent(), ) { assertEquals( box("error", "Error", "Hello, world!") + box("tip", "Tip", "Hello, world!") + box("callout", null, "Hello, world!"), it, ) } } @Test fun `unsupported localization`() { execute( """ .doclang {akan} .box type:{warning} Hello, world! """.trimIndent(), ) { assertEquals( box("warning", null, "Hello, world!"), it, ) } } @Test fun `to-do`() { execute( """ .doclang {italian} .todo {Hello, world!} """.trimIndent(), ) { assertEquals( box("warning", "DA FARE", "Hello, world!"), it, ) } } @Test fun `to-do with fallback localization for missing locale`() { execute( """ .todo {Hello, world!} """.trimIndent(), ) { assertEquals( box("warning", "TO DO", "Hello, world!"), it, ) } } @Test fun `to-do with fallback localization for unsupported locale`() { execute( """ .doclang {akan} .todo {Hello, world!} """.trimIndent(), ) { assertEquals( box("warning", "TO DO", "Hello, world!"), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/CaptionTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for [com.quarkdown.core.ast.quarkdown.CaptionableNode]s. */ class CaptionTest { @Test fun figure() { execute( """ ![](https://example.com/image.png "Figure caption") """.trimIndent(), ) { assertEquals( "<figure><img src=\"https://example.com/image.png\" alt=\"\" title=\"Figure caption\" />" + "<figcaption class=\"caption-bottom\">Figure caption</figcaption></figure>", it, ) } } @Test fun `figure via function`() { execute( """ .figure caption:{Figure caption} Hello """.trimIndent(), ) { assertEquals( "<figure><p>Hello</p><figcaption class=\"caption-bottom\">Figure caption</figcaption></figure>", it, ) } } @Test fun `table, one row`() { execute( """ | Header 1 | Header 2 | Header 3 | |----------|:--------:|----------| | Cell 1 | Cell 2 | Cell 3 | "Table caption" """.trimIndent(), ) { assertEquals( "<table><thead><tr><th>Header 1</th><th align=\"center\">Header 2</th>" + "<th>Header 3</th></tr></thead><tbody><tr><td>Cell 1</td>" + "<td align=\"center\">Cell 2</td><td>Cell 3</td></tr></tbody>" + "<caption class=\"caption-bottom\">Table caption</caption></table>", it, ) } } @Test fun `table, three rows, caption on top`() { execute( """ .captionposition tables:{top} | Header 1 | Header 2 | Header 3 | |----------|:--------:|----------| | Cell 1 | Cell 2 | Cell 3 | | Cell 4 | Cell 5 | Cell 6 | | Cell 7 | Cell 8 | Cell 9 | "Table caption" """.trimIndent(), ) { assertEquals( "<table><thead><tr><th>Header 1</th><th align=\"center\">Header 2</th>" + "<th>Header 3</th></tr></thead><tbody><tr><td>Cell 1</td>" + "<td align=\"center\">Cell 2</td><td>Cell 3</td></tr>" + "<tr><td>Cell 4</td><td align=\"center\">Cell 5</td><td>Cell 6</td></tr>" + "<tr><td>Cell 7</td><td align=\"center\">Cell 8</td><td>Cell 9</td></tr></tbody>" + "<caption class=\"caption-top\">Table caption</caption></table>", it, ) } } @Test fun `code block`() { execute( """ ```javascript "Logging code" console.log("Hello, world!"); ``` """.trimIndent(), ) { assertEquals( "<figure><pre><code class=\"language-javascript\">console.log(&quot;Hello, world!&quot;);</code></pre>" + "<figcaption class=\"caption-bottom\">Logging code</figcaption></figure>", it, ) } } @Test fun `code block, from function`() { execute( """ .code lang:{javascript} caption:{Logging code} console.log("Hello, world!"); """.trimIndent(), ) { assertEquals( "<figure><pre><code class=\"language-javascript\">console.log(&quot;Hello, world!&quot;);</code></pre>" + "<figcaption class=\"caption-bottom\">Logging code</figcaption></figure>", it, ) } } @Test fun `all captions on top`() { execute( """ .captionposition default:{top} ![](https://example.com/image.png "Figure caption") | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Table caption" .mermaid caption:{Mermaid caption} graph TD """.trimIndent(), ) { assertEquals( "<figure><img src=\"https://example.com/image.png\" alt=\"\" title=\"Figure caption\" />" + "<figcaption class=\"caption-top\">Figure caption</figcaption></figure>" + "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody>" + "<caption class=\"caption-top\">Table caption</caption></table>" + "<figure><pre class=\"mermaid fill-height\">graph TD</pre>" + "<figcaption class=\"caption-top\">Mermaid caption</figcaption></figure>", it, ) } } @Test fun `all captions on top but figures`() { execute( """ .captionposition default:{top} figures:{bottom} ![](https://example.com/image.png "Figure caption") | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "Table caption" """.trimIndent(), ) { assertEquals( "<figure><img src=\"https://example.com/image.png\" alt=\"\" title=\"Figure caption\" />" + "<figcaption class=\"caption-bottom\">Figure caption</figcaption></figure>" + "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody>" + "<caption class=\"caption-top\">Table caption</caption></table>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/CodeTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.attributes.presence.hasCode import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** * Tests for code blocks and inline code. */ class CodeTest { @Test fun inline() { execute("`println(\"Hello, world!\")`") { assertEquals( "<p><span class=\"codespan-content\"><code>println(&quot;Hello, world!&quot;)</code></span></p>", it, ) assertFalse(attributes.hasCode) } } @Test fun `hex color preview`() { execute("`#FF0000`") { assertEquals( "<p>" + "<span class=\"codespan-content\">" + "<code>#FF0000</code>" + "<span style=\"background-color: rgba(255, 0, 0, 1.0);\" class=\"color-preview\"></span>" + "</span>" + "</p>", it, ) assertFalse(attributes.hasCode) } } @Test fun `rgb color preview`() { execute("`rgba(200, 100, 50, 0.5)`") { assertEquals( "<p>" + "<span class=\"codespan-content\">" + "<code>rgba(200, 100, 50, 0.5)</code>" + "<span style=\"background-color: rgba(200, 100, 50, 0.5);\" class=\"color-preview\"></span>" + "</span>" + "</p>", it, ) assertFalse(attributes.hasCode) } } @Test fun block() { execute("```\nprintln(\"Hello, world!\")\n```") { assertEquals("<pre><code>println(&quot;Hello, world!&quot;)</code></pre>", it) assertTrue(attributes.hasCode) } } @Test fun `block with language`() { execute("```kotlin\nprintln(\"Hello, world!\")\n```") { assertEquals("<pre><code class=\"language-kotlin\">println(&quot;Hello, world!&quot;)</code></pre>", it) assertTrue(attributes.hasCode) } } @Test fun `block with language and indentation`() { execute("```kotlin\nfun hello() {\n println(\"Hello, world!\")\n}\n```") { assertEquals( "<pre><code class=\"language-kotlin\">fun hello() {\n println(&quot;Hello, world!&quot;)\n}</code></pre>", it, ) assertTrue(attributes.hasCode) } } // #259 @Test fun `indented block`() { execute( """ - indented context .br ``` aaaa bbbb cccc ``` """.trimIndent(), ) { assertEquals( "<ul><li>indented context <br />" + "<pre><code>aaaa\nbbbb\ncccc</code></pre>" + "</li></ul>", it, ) } } // #32 @Test fun `long block`() { execute( """ ```nohighlight 1/1 file.ext - this_is_a_very_long_function_name_indeed() 59/59 file.ext - this_is_a_very_long_function_name_indeed() 11/11 file.ext - this_is_a_very_long_function_name_indeed() 11/11 file.ext - this_is_a_very_long_function_name_indeed() 11/11 file.ext - this_is_a_very_long_function_name_indeed() 15/15 file.ext - this_is_a_very_long_function_name_indeed() 4/4 file.ext - this_is_a_very_long_function_name_indeed() 13/13 file.ext - this_is_a_very_long_function_name_indeed() 14/14 file.ext - this_is_a_very_long_function_name_indeed() 4/4 file.ext - this_is_a_very_long_function_name_indeed() 15/15 file.ext - this_is_a_very_long_function_name_indeed() 19/19 file.ext - this_is_a_very_long_function_name_indeed() 12/12 file.ext - this_is_a_very_long_function_name_indeed() 4/4 file.ext - this_is_a_very_long_function_name_indeed() 14/14 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 8/8 file.ext - this_is_a_very_long_function_name_indeed() 13/13 file.ext - this_is_a_very_long_function_name_indeed() 4/4 file.ext - this_is_a_very_long_function_name_indeed() 16/16 file.ext - this_is_a_very_long_function_name_indeed() *** 19/21 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 2/2 file.ext - this_is_a_very_long_function_name_indeed() 16/16 file.ext - this_is_a_very_long_function_name_indeed() *** 23/25 file.ext - this_is_a_very_long_function_name_indeed() 1/1 file.ext - this_is_a_very_long_function_name_indeed() 1/1 file.ext - this_is_a_very_long_function_name_indeed() 7/7 file.ext - this_is_a_very_long_function_name_indeed() 3/3 file.ext - this_is_a_very_long_function_name_indeed() *** 81/82 file_1.ext - this_is_a_very_long_function_name_indeed() *** 0/87 file_1.ext - this_is_a_very_long_function_name_indeed() ``` """.trimIndent(), ) { assertTrue(it.startsWith("<pre><code class=\"language-nohighlight\">")) assertTrue(it.endsWith("</code></pre>")) assertEquals(35, it.lines().size) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/CommentsTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for Markdown comments. */ class CommentsTest { @Test fun `only comment`() { execute("<!-- comment -->") { assertEquals("", it) } } @Test fun `comment as block`() { execute( """ <!-- comment --> Hello <!-- comment --> World <!-- comment --> """.trimIndent(), ) { assertEquals("<p>Hello</p><p>World</p>", it) } } @Test fun `comment as inline`() { execute( """ Hello <!-- comment --> World """.trimIndent(), ) { assertEquals("<p>Hello World</p>", it) } execute( """ Hello<!-- comment -->World """.trimIndent(), ) { assertEquals("<p>HelloWorld</p>", it) } } @Test fun `comment across lines`() { execute( """ Hello <!-- comment with new lines --> World """.trimIndent(), ) { assertEquals("<p>Hello World</p>", it) } } @Test fun `comment in block markdown argument`() { execute( """ .container <!-- comment --> Hello """.trimIndent(), ) { assertEquals("<div class=\"container\"><p>Hello</p></div>", it) } } @Test fun `comment in block lambda argument`() { execute( """ .if {yes} <!-- comment --> Hello """.trimIndent(), ) { assertEquals("<p>Hello</p>", it) } } @Test fun `comment in static-value block argument is not a comment`() { execute( """ .uppercase <!-- comment --> Hi """.trimIndent(), ) { assertEquals("<p><!-- COMMENT -->\nHI</p>", it) } } @Test fun `comment in inline argument`() { execute(".sum {1} {<!-- comment -->3}") { assertEquals("<p>4</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/CrossReferenceTest.kt ================================================ package com.quarkdown.test import com.quarkdown.rendering.plaintext.extension.plainText import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals /** * Tests for cross-references. */ class CrossReferenceTest { @Test fun `invalid reference`() { execute("See .ref {x}") { assertEquals("<p>See [???]</p>", it) } } @Test fun `reference after definition (heading)`() { execute( """ ## Title {#my-ref} See .ref {my-ref}. """.trimIndent(), ) { assertEquals( "<h2 id=\"my-ref\">Title</h2>" + "<p>See <a href=\"#my-ref\"><span class=\"cross-reference\">Title</span></a>.</p>", it, ) } } @Test fun `reference before definition (heading)`() { execute( """ See .ref {my-ref}. ## Title {#my-ref} """.trimIndent(), ) { assertEquals( "<p>See <a href=\"#my-ref\"><span class=\"cross-reference\">Title</span></a>.</p>" + "<h2 id=\"my-ref\">Title</h2>", it, ) } } @Test fun `multiple references to the same definition (heading)`() { execute( """ See .ref {my-ref} and .ref {my-ref}. ## Title {#my-ref} """.trimIndent(), ) { assertEquals( "<p>See <a href=\"#my-ref\"><span class=\"cross-reference\">Title</span></a> and " + "<a href=\"#my-ref\"><span class=\"cross-reference\">Title</span></a>.</p>" + "<h2 id=\"my-ref\">Title</h2>", it, ) } } @Test fun `mutual references (heading)`() { execute( """ See .ref {ref-a}. ## Title A {#ref-a} See also .ref {ref-b}. ## Title B {#ref-b} Back to .ref {ref-a}. """.trimIndent(), ) { assertEquals( "<p>See <a href=\"#ref-a\"><span class=\"cross-reference\">Title A</span></a>.</p>" + "<h2 id=\"ref-a\">Title A</h2>" + "<p>See also <a href=\"#ref-b\"><span class=\"cross-reference\">Title B</span></a>.</p>" + "<h2 id=\"ref-b\">Title B</h2>" + "<p>Back to <a href=\"#ref-a\"><span class=\"cross-reference\">Title A</span></a>.</p>", it, ) } } @Test fun `numbered references (heading)`() { execute( """ .noautopagebreak .numbering - headings: 1.1 See .ref {first-ref} and .ref {second-ref}. # Title {#first-ref} ## Subitle {#second-ref} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <a href=\"#first-ref\"><span class=\"cross-reference\" data-location=\"1\"></span></a>" + " and <a href=\"#second-ref\"><span class=\"cross-reference\" data-location=\"1.1\"></span></a>.</p>" + "<h1 id=\"first-ref\" data-location=\"1\">Title</h1>" + "<h2 id=\"second-ref\" data-location=\"1.1\">Subitle</h2>", it, ) } } @Test fun `localized numbered references (heading)`() { execute( """ .noautopagebreak .doclang {en} .numbering - headings: 1.1 See .ref {first-ref} and .ref {second-ref}. # Title {#first-ref} ## Subitle {#second-ref} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <a href=\"#first-ref\">" + "<span class=\"cross-reference\" data-location=\"1\" data-localized-kind=\"Section\"></span></a>" + " and <a href=\"#second-ref\">" + "<span class=\"cross-reference\" data-location=\"1.1\" data-localized-kind=\"Section\"></span></a>.</p>" + "<h1 id=\"first-ref\" data-location=\"1\">Title</h1>" + "<h2 id=\"second-ref\" data-location=\"1.1\">Subitle</h2>", it, ) } } @Test fun `localized numbered references (heading, plaintext)`() { execute( """ .doclang {en} .numbering - headings: 1.1 See .ref {first-ref} and .ref {second-ref}. # Title {#first-ref} ## Subtitle {#second-ref} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), renderer = { factory, ctx -> factory.plainText(ctx) }, ) { assertEquals( "See Section 1 and Section 1.1.\n\n" + "Title\n\n" + "Subtitle\n\n", it, ) } } @Test fun `reference after definition (figure, no caption)`() { execute( """ ![My Image](img.png) {#my-fig} See .ref {my-fig}. """.trimIndent(), ) { assertEquals( "<figure><img src=\"img.png\" alt=\"My Image\" /></figure>" + "<p>See <span class=\"cross-reference\">my-fig</span>.</p>", it, ) } } @Test fun `reference after definition (figure, with caption)`() { execute( """ ![My Image](img.png "The caption") {#my-fig} See .ref {my-fig}. """.trimIndent(), ) { assertEquals( "<figure><img src=\"img.png\" alt=\"My Image\" title=\"The caption\" />" + "<figcaption class=\"caption-bottom\">The caption</figcaption></figure>" + "<p>See <span class=\"cross-reference\">The caption</span>.</p>", it, ) } } @Test fun `multiple references to the same definition (figure)`() { execute( """ See .ref {my-fig} and .ref {my-fig}. ![My Image](img.png) {#my-fig} """.trimIndent(), ) { assertEquals( "<p>See <span class=\"cross-reference\">my-fig</span> and " + "<span class=\"cross-reference\">my-fig</span>.</p>" + "<figure><img src=\"img.png\" alt=\"My Image\" /></figure>", it, ) } } @Test fun `numbered references (figure)`() { execute( """ .noautopagebreak .numbering - figures: a See .ref {my-fig}. ![My Image](img.png "The caption") {#my-fig} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"a\"></span>.</p>" + "<figure id=\"figure-a\">" + "<img src=\"img.png\" alt=\"My Image\" title=\"The caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"a\">The caption</figcaption>" + "</figure>", it, ) } } @Test fun `localized numbered references (figure)`() { execute( """ .noautopagebreak .doclang {en} .numbering - figures: a See .ref {my-fig}. ![My Image](img.png "The caption") {#my-fig} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"a\" data-localized-kind=\"Figure\"></span>.</p>" + "<figure id=\"figure-a\">" + "<img src=\"img.png\" alt=\"My Image\" title=\"The caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"a\" data-localized-kind=\"Figure\">The caption</figcaption>" + "</figure>", it, ) } } @Test fun `numbered references (mermaid figure)`() { execute( """ .noautopagebreak .numbering - figures: A See .ref {my-diagram} and .ref {my-other-diagram}. .mermaid ref:{my-diagram} graph TD A --> B .mermaid caption:{My other diagram} ref:{my-other-diagram} graph TD A --> B """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"A\"></span>" + " and <span class=\"cross-reference\" data-location=\"B\"></span>.</p>" + "<figure id=\"figure-A\">" + "<pre class=\"mermaid fill-height\">graph TD\n A --&gt; B</pre>" + "<figcaption class=\"caption-bottom\" data-location=\"A\"></figcaption>" + "</figure>" + "<figure id=\"figure-B\">" + "<pre class=\"mermaid fill-height\">graph TD\n A --&gt; B</pre>" + "<figcaption class=\"caption-bottom\" data-location=\"B\">My other diagram</figcaption>" + "</figure>", it, ) } } @Test fun `numbered references (custom figure)`() { execute( """ .numbering - figures: 1 See .ref {my-fig} and .ref {my-other-fig}. .figure ref:{my-fig} This is a custom figure. .figure caption:{My caption} ref:{my-other-fig} This is another custom figure. """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"1\"></span> and " + "<span class=\"cross-reference\" data-location=\"2\"></span>.</p>" + "<figure id=\"figure-1\">" + "<p>This is a custom figure.</p>" + "<figcaption class=\"caption-bottom\" data-location=\"1\"></figcaption>" + "</figure>" + "<figure id=\"figure-2\">" + "<p>This is another custom figure.</p>" + "<figcaption class=\"caption-bottom\" data-location=\"2\">My caption</figcaption>" + "</figure>", it, ) } } @Test fun `reference before definition (table)`() { execute( """ See .ref {my-table}. | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | {#my-table} """.trimIndent(), ) { assertEquals( "<p>See <span class=\"cross-reference\">my-table</span>.</p>" + "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>", it, ) } } @Test fun `localized numbered references (table)`() { execute( """ .doclang {en} .numbering - tables: i See .ref {my-table} and .ref {my-other-table}. | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | {#my-table} | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | "My caption" {#my-other-table} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"i\" data-localized-kind=\"Table\"></span> " + "and <span class=\"cross-reference\" data-location=\"ii\" data-localized-kind=\"Table\"></span>.</p>" + "<table id=\"table-i\"><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"i\" data-localized-kind=\"Table\"></caption></table>" + "<table id=\"table-ii\"><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"ii\" data-localized-kind=\"Table\">My caption</caption></table>", it, ) } } @Test fun `numbered references (csv table)`() { execute( """ .numbering - tables: 1 See .ref {my-table} and .ref {my-other-table}. .csv {csv/people.csv} ref:{my-table} | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | {#my-other-table} """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertContains( it, "<p>See <span class=\"cross-reference\" data-location=\"1\"></span> and " + "<span class=\"cross-reference\" data-location=\"2\"></span>.</p>", ) assertContains( it, "<caption class=\"caption-bottom\" data-location=\"1\"></caption></table>", ) assertContains( it, "<caption class=\"caption-bottom\" data-location=\"2\"></caption></table>", ) } } @Test fun `numbered references (math)`() { execute( """ .numbering - equations: 1 See .ref {my-math} and .ref {my-other-math}. $ E = mc^2 $ {#my-math} $$$ {#my-other-math} E = mc^2 $$$ """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"1\"></span> and " + "<span class=\"cross-reference\" data-location=\"2\"></span>.</p>" + "<formula data-block=\"\" data-location=\"1\">E = mc^2</formula>" + "<formula data-block=\"\" data-location=\"2\">E = mc^2</formula>", it, ) } } @Test fun `reference before definition (code block)`() { execute( """ See .ref {my-code}. ```kotlin {#my-code} println("Hello, World!") ``` """.trimIndent(), ) { assertEquals( "<p>See <span class=\"cross-reference\">my-code</span>.</p>" + "<pre><code class=\"language-kotlin\">println(&quot;Hello, World!&quot;)</code></pre>", it, ) } } @Test fun `reference before definition (code block from function)`() { execute( """ See .ref {my-code}. .code lang:{kotlin} ref:{my-code} println("Hello, World!") """.trimIndent(), ) { assertEquals( "<p>See <span class=\"cross-reference\">my-code</span>.</p>" + "<pre><code class=\"language-kotlin\">println(&quot;Hello, World!&quot;)</code></pre>", it, ) } } @Test fun `localized numbered references (code block)`() { execute( """ .doclang {en} .numbering - code: I See .ref {my-code} and .ref {my-other-code}. ```kotlin {#my-code} println("Hello, World!") ``` ```kotlin {#my-other-code} println( "Hello, World!" ) ``` """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"I\" data-localized-kind=\"Listing\"></span> and " + "<span class=\"cross-reference\" data-location=\"II\" data-localized-kind=\"Listing\"></span>.</p>" + "<figure id=\"listing-I\"><pre><code class=\"language-kotlin\">" + "println(&quot;Hello, World!&quot;)</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"I\" data-localized-kind=\"Listing\"></figcaption></figure>" + "<figure id=\"listing-II\"><pre><code class=\"language-kotlin\">" + "println(\n &quot;Hello, World!&quot;\n)</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"II\" data-localized-kind=\"Listing\"></figcaption></figure>", it, ) } } @Test fun `custom numbered blocks`() { execute( """ .numbering - myblock: a See .ref {block1} and .ref {block2}. .numbered {myblock} ref:{block1} Block .1 .numbered {myblock} ref:{block2} Block .1 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p>See <span class=\"cross-reference\" data-location=\"a\"></span> and " + "<span class=\"cross-reference\" data-location=\"b\"></span>.</p>" + "<p>Block a</p>" + "<p>Block b</p>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/DocsLibraryTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.document.DocumentType import com.quarkdown.test.util.DATA_FOLDER import com.quarkdown.test.util.execute import java.io.File import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for the `docs` library, which */ class DocsLibraryTest { @Test fun `multi-page docs with docs library`() { val pageCount = 3 execute( ".include {docs}", workingDirectory = File(DATA_FOLDER, "subdoc").resolve("docs"), loadableLibraries = setOf("docs"), ) { // Subdocuments: 3 pages + 1 root. assertEquals(pageCount + 1, subdocumentGraph.vertices.size) // The graph is fully connected (remember that this block is run for each subdocument). assertEquals(pageCount, subdocumentGraph.getNeighbors(subdocument).count()) assertEquals(DocumentType.DOCS, documentInfo.type) assertEquals("en", documentInfo.locale?.code) assertEquals("Common description", documentInfo.description) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/DocumentTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.AstRoot import com.quarkdown.core.ast.attributes.presence.hasCode import com.quarkdown.core.ast.attributes.presence.hasMath import com.quarkdown.core.ast.quarkdown.block.Container import com.quarkdown.core.document.DocumentAuthor import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.layout.page.PageOrientation import com.quarkdown.core.document.layout.page.PageSizeFormat import com.quarkdown.core.document.size.Size import com.quarkdown.core.document.size.Sizes import com.quarkdown.core.misc.color.NamedColor import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.stdlib.pageFormat import com.quarkdown.stdlib.paragraphStyle import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue /** * Tests for document metadata and attributes. */ class DocumentTest { @Test fun `initial state`() { execute("") { assertEquals("", it) assertIs<AstRoot>(attributes.root) assertFalse(attributes.hasCode) assertFalse(attributes.hasMath) assertEquals(DocumentType.PLAIN, documentInfo.type) assertNull(documentInfo.name) assertEquals(0, documentInfo.authors.size) assertNull(documentInfo.description) assertTrue(documentInfo.keywords.isEmpty()) assertNull(documentInfo.locale) assertTrue(documentInfo.layout.pageFormats.isEmpty()) assertNull(documentInfo.layout.paragraphStyle.spacing) } } @Test fun `document setup`() { execute( """ .docname {My Quarkdown document} .docdescription {A comprehensive guide to Quarkdown} .dockeywords - documentation - markdown - typesetting .docauthors - iamgio - website: https://iamgio.eu - Giorgio - website: https://github.com/iamgio - Gio .doctype {slides} .doclang {english} .theme {darko} layout:{minimal} .pageformat size:{A3} orientation:{landscape} margin:{3cm 2px} bordercolor:{green} columns:{4} alignment:{end} .paragraphstyle lineheight:{2.0} spacing:{1.5} indent:{2} .slides transition:{zoom} speed:{fast} .autopagebreak maxdepth:{3} """.trimIndent(), ) { assertEquals("My Quarkdown document", documentInfo.name) assertEquals("A comprehensive guide to Quarkdown", documentInfo.description) assertEquals(listOf("documentation", "markdown", "typesetting"), documentInfo.keywords) assertEquals( listOf( DocumentAuthor("iamgio", mapOf("website" to "https://iamgio.eu")), DocumentAuthor("Giorgio", mapOf("website" to "https://github.com/iamgio")), DocumentAuthor("Gio", mapOf()), ), documentInfo.authors, ) assertEquals("en", documentInfo.locale?.tag) assertEquals(DocumentType.SLIDES, documentInfo.type) assertEquals("darko", documentInfo.theme?.color) assertEquals("minimal", documentInfo.theme?.layout) val pageFormat = documentInfo.layout.pageFormats.last() PageSizeFormat.A3.getBounds(PageOrientation.LANDSCAPE).let { bounds -> assertEquals(bounds.width, pageFormat.pageWidth) assertEquals(bounds.height, pageFormat.pageHeight) } assertEquals( Sizes( vertical = Size(3.0, Size.Unit.CENTIMETERS), horizontal = Size(2.0, Size.Unit.PIXELS), ), pageFormat.margin, ) assertNull(pageFormat.contentBorderWidth) assertEquals(NamedColor.GREEN.color, pageFormat.contentBorderColor) assertEquals(4, pageFormat.columnCount) assertEquals(Container.TextAlignment.END, pageFormat.alignment) assertEquals(2.0, documentInfo.layout.paragraphStyle.lineHeight) assertEquals(1.5, documentInfo.layout.paragraphStyle.spacing) assertEquals(2.0, documentInfo.layout.paragraphStyle.indent) } } @Test fun `document cannot have blank name`() { assertFails { execute(".docname { }") {} } execute(".docname { }", errorHandler = BasePipelineErrorHandler()) { assertNull(documentInfo.name) } } @Test fun `document metadata echo`() { execute( """ .docname {My Quarkdown document} .dockeywords - quarkdown - markdown - documentation .docauthors - iamgio - country: Italy .doctype {slides} .doclang {english} .docdescription A comprehensive guide to Quarkdown .docname .text {.docname} size:{tiny}. .docdescription .docauthors #! .docauthor .doctype .doclang .dockeywords """.trimIndent(), ) { assertEquals( "<p>My Quarkdown document " + "<span class=\"size-tiny\">My Quarkdown document</span>.</p>" + "<p>A comprehensive guide to Quarkdown</p>" + "<table>" + "<thead><tr><th>Key</th><th>Value</th></tr></thead>" + "<tbody>" + "<tr><td>iamgio</td><td>" + "<table><thead><tr><th>Key</th><th>Value</th></tr></thead>" + "<tbody><tr><td>country</td><td><p>Italy</p></td></tr></tbody></table></td></tr>" + "</tbody>" + "</table>" + "<h1 data-decorative=\"\">iamgio</h1>" + "<p>slides</p>" + "<p>English</p>" + "<ol><li><p>quarkdown</p></li><li><p>markdown</p></li><li><p>documentation</p></li></ol>", it, ) } } @Test fun `document info modification from scope`() { execute( """ .docname {Original Name} .if {yes} .docname {Modified Name} """.trimIndent(), ) { assertEquals("Modified Name", documentInfo.name) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/EcosystemTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.function.quarkdownName import com.quarkdown.core.log.Log import com.quarkdown.core.pipeline.error.StrictPipelineErrorHandler import com.quarkdown.stdlib.ContextSandbox import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertTrue private const val OUTPUT_BASIC_SOURCE = "<h1>Title</h1><p>Some <em>text</em>.</p>" private const val OUTPUT_FUNCTION_WITH_CONTENT = "<h2>Included</h2><pre><code>code\ncode</code></pre>" private const val OUTPUT_ABSOLUTE_IMAGE = "<p>img: <img src=\"/img/icon.png\" alt=\"img\" /></p>" private fun forSandboxes( vararg sandboxes: ContextSandbox, expectOthersFail: Boolean = true, block: (String) -> Unit, ) { sandboxes.forEach { Log.info("Testing ecosystem success in sandbox: ${it.quarkdownName}") block(it.quarkdownName) } if (expectOthersFail) { val others = ContextSandbox.entries.toSet() - sandboxes.toSet() others.forEach { sandbox -> Log.info("Testing ecosystem failure in sandbox: ${sandbox.quarkdownName}") assertFails { block(sandbox.quarkdownName) } } } } /** * Tests for including files and libraries. */ class EcosystemTest { @Test fun `include source`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .noautopagebreak .include {include/basic-source.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( OUTPUT_BASIC_SOURCE, it, ) } } } @Test fun `include source with stdlib call`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .include {include/stdlib-call.md} sandbox:{$sandbox} """.trimIndent(), ) { assertContains(it, "Lorem ipsum") } } } @Test fun `modify document info from included source`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE) { sandbox -> execute( """ .include {include/document-info-modification.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals("Modified Title", documentInfo.name) assertEquals("it", documentInfo.locale?.shortTag) } } } @Test fun `include function from source`() { forSandboxes(ContextSandbox.SHARE) { sandbox -> execute( """ .include {include/function-definition.md} sandbox:{$sandbox} .hello {world} """.trimIndent(), ) { assertEquals( "<p>Hello, world!</p>", it, ) } } } @Test fun `include uncalled function plus content from source`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .noautopagebreak # Main .include {include/function-with-content.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( "<h1>Main</h1>$OUTPUT_FUNCTION_WITH_CONTENT", it, ) } } } @Test fun `share function with included files`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .function {hello} x: Hello, .x! .include {include/shared-function-usage.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( "<h3>Hello, world!</h3>", it, ) } } } @Test fun `transitive inclusion`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .noautopagebreak # Main .include {include/transitive-include.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( "<h1>Main</h1><h1>Included</h1><p>Hello, Gio!</p><h3>Hello, world!</h3>", it, ) } } } @Test fun `invalid usage as value`() { // Included file cannot be used as a dynamic value. assertFails { execute( """ .sum {.include {include/dynamic-value.md}} {3} """.trimIndent(), ) {} } } @Test fun `mutate included data`() { forSandboxes(ContextSandbox.SHARE) { sandbox -> execute( """ .include {include/mutable-data.md} sandbox:{$sandbox} .saygreeting .var {mygreeting} {Hello} .saygreeting """.trimIndent(), ) { assertEquals( "<p>Hi</p><p>Hello</p>", it, ) } } } @Test fun `'read' call from updated working directory`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .include {include/read-relative-path.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( "<p>Line 1\nLine 2\n\nLine 3</p>", it, ) } } } @Test fun `'read' call from updated working directory inside scope context`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .include {include/read-relative-path-in-scope.md} sandbox:{$sandbox} """.trimIndent(), ) { assertEquals( "<p>Line 1\nLine 2\n\nLine 3</p>".repeat(2), it, ) } } } @Test fun `relative-path image from updated working directory`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> execute( """ .include {include/relative-image.md} """.trimIndent(), ) { assertEquals( "<p>img: <img src=\"img/icon.png\" alt=\"img\" /></p>", it, ) } } } @Test fun `absolute-path image from updated working directory should not be updated`() { execute( """ .include {include/absolute-image.md} """.trimIndent(), ) { assertEquals( "<p>img: <img src=\"/img/icon.png\" alt=\"img\" /></p>", it, ) } } @Test fun `url image from updated working directory should not be updated`() { execute( """ .include {include/url-image.md} """.trimIndent(), ) { assertEquals( "<p>img: <img src=\"https://example.com/img/icon.png\" alt=\"img\" /></p>", it, ) } } @Test fun `relative-path reference image from updated working directory`() { execute( """ .include {include/reference-image.md} """.trimIndent(), ) { assertEquals( "<p>img: <img src=\"images/picture.png\" alt=\"img\" /></p>", it, ) } } @Test fun `include library`() { forSandboxes(ContextSandbox.SHARE, ContextSandbox.SCOPE, ContextSandbox.SUBDOCUMENT) { sandbox -> // Load library named 'hello' from libraries/hello.qd execute( """ .include {hello} sandbox:{$sandbox} .hellofromlib {world} """.trimIndent(), loadableLibraries = setOf("hello"), useDummyLibraryDirectory = true, ) { assertEquals( "<p>Hello, <em>world</em>!</p>", it, ) } } } @Test fun `invocation of unincluded library`() { assertFails { execute( """ .hellofromlib {world} """.trimIndent(), loadableLibraries = setOf("hello"), useDummyLibraryDirectory = true, errorHandler = StrictPipelineErrorHandler(), ) {} } } @Test fun `inclusion of unexisting library`() { // Not available in the environment. assertFails { execute( """ .include {hello} .hellofromlib {world} """.trimIndent(), errorHandler = StrictPipelineErrorHandler(), useDummyLibraryDirectory = true, ) {} } } @Test fun `include library content`() { execute( ".include {content}", loadableLibraries = setOf("content"), useDummyLibraryDirectory = true, ) { assertEquals( "<h2>Title</h2><p>Content</p>", it, ) } } @Test fun `library keeps includer's file system`() { execute( ".include {file-reader}", loadableLibraries = setOf("file-reader"), useDummyLibraryDirectory = true, ) { // 2 occurrences of <table> assertEquals(2, it.split("<table>").size - 1) } } @Test fun `include multiple sources`() { forSandboxes(ContextSandbox.SHARE) { sandbox -> execute( """ .noautopagebreak .include {include/basic-source.md} sandbox:{$sandbox} .include {include/function-with-content.md} sandbox:{$sandbox} .hello {world} """.trimIndent(), ) { assertEquals( "$OUTPUT_BASIC_SOURCE$OUTPUT_FUNCTION_WITH_CONTENT<p>Hello, world!</p>", it, ) } } } @Test fun `include multiple sources via bulk`() { execute( """ .noautopagebreak .includeall - include/basic-source.md - include/function-with-content.md .hello {world} """.trimIndent(), ) { assertEquals( "$OUTPUT_BASIC_SOURCE$OUTPUT_FUNCTION_WITH_CONTENT<p>Hello, world!</p>", it, ) } } @Test fun `bulk-include all from directory`() { execute( """ .noautopagebreak .includeall {.listfiles {include} sortby:{name}} """.trimIndent(), ) { if (subdocument == Subdocument.Root) { assertTrue(it.startsWith(OUTPUT_ABSOLUTE_IMAGE + OUTPUT_BASIC_SOURCE)) } } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/EmojiTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for the `emoji` stdlib function. */ class EmojiTest { @Test fun `normal emoji`() { execute(".emoji {wink}") { assertEquals("<p>\uD83D\uDE09</p>", it) } } @Test fun `emoji with skin tone`() { execute(".emoji {waving-hand~medium-dark}") { assertEquals("<p>\uD83D\uDC4B\uD83C\uDFFE</p>", it) } } @Test fun `emoji with two skin tones`() { execute(".emoji {people-holding-hands~medium-light,medium-dark}") { assertEquals("<p>\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1\uD83C\uDFFE</p>", it) } } @Test fun `unknown emoji`() { execute(".emoji {unknown}") { assertEquals("<p>:unknown:</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FileTreeTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for file tree generation and rendering. */ class FileTreeTest { @Test fun `files only`() { execute( """ .filetree - file1.txt - file2.json - file3.csv """.trimIndent(), ) { assertEquals( "<div class=\"file-tree\"><ul>" + "<li class=\"file\" data-name=\"file1.txt\">file1.txt</li>" + "<li class=\"file\" data-name=\"file2.json\">file2.json</li>" + "<li class=\"file\" data-name=\"file3.csv\">file3.csv</li>" + "</ul></div>", it, ) } } @Test fun `single directory`() { execute( """ .filetree - src - main.ts - utils.ts """.trimIndent(), ) { assertEquals( "<div class=\"file-tree\"><ul>" + "<li class=\"directory\" data-name=\"src\">src" + "<ul>" + "<li class=\"file\" data-name=\"main.ts\">main.ts</li>" + "<li class=\"file\" data-name=\"utils.ts\">utils.ts</li>" + "</ul>" + "</li>" + "</ul></div>", it, ) } } @Test fun `nested directories`() { execute( """ .filetree - src - components - Button.ts - index.ts - README.md """.trimIndent(), ) { assertEquals( "<div class=\"file-tree\"><ul>" + "<li class=\"directory\" data-name=\"src\">src" + "<ul>" + "<li class=\"directory\" data-name=\"components\">components" + "<ul>" + "<li class=\"file\" data-name=\"Button.ts\">Button.ts</li>" + "</ul>" + "</li>" + "<li class=\"file\" data-name=\"index.ts\">index.ts</li>" + "</ul>" + "</li>" + "<li class=\"file\" data-name=\"README.md\">README.md</li>" + "</ul></div>", it, ) } } @Test fun highlighted() { execute( """ .filetree - file1.txt - **file2.txt** - **src** - **main.ts** - utils.ts - **...** """.trimIndent(), ) { assertEquals( "<div class=\"file-tree\"><ul>" + "<li class=\"file\" data-name=\"file1.txt\">file1.txt</li>" + "<li class=\"file\" data-name=\"file2.txt\" data-highlighted=\"\">file2.txt</li>" + "<li class=\"directory\" data-name=\"src\" data-highlighted=\"\">src" + "<ul>" + "<li class=\"file\" data-name=\"main.ts\" data-highlighted=\"\">main.ts</li>" + "<li class=\"file\" data-name=\"utils.ts\">utils.ts</li>" + "</ul>" + "</li>" + "<li class=\"ellipsis\" data-highlighted=\"\">&hellip;</li>" + "</ul></div>", it, ) } } @Test fun ellipsis() { execute( """ .filetree - src - main.ts - ... - README.md """.trimIndent(), ) { assertEquals( "<div class=\"file-tree\"><ul>" + "<li class=\"directory\" data-name=\"src\">src" + "<ul>" + "<li class=\"file\" data-name=\"main.ts\">main.ts</li>" + "<li class=\"ellipsis\">&hellip;</li>" + "</ul>" + "</li>" + "<li class=\"file\" data-name=\"README.md\">README.md</li>" + "</ul></div>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FontTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.misc.font.FontFamily import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull /** * Tests for resolving, loading and applying fonts. */ class FontTest { private val localPath = "font/NotoSans-Regular.ttf" @Test fun `local font`() { execute(".font main:{$localPath}") { val fontConfiguration = documentInfo.layout.fonts.first() val fontFamily = fontConfiguration.mainFamily val media = mediaStorage.resolve(localPath) assertIs<FontFamily.Media>(fontFamily) assertNull(fontConfiguration.headingFamily) assertNull(fontConfiguration.codeFamily) assertNotNull(media) assertEquals(fontFamily.media, media.media) } } @Test fun `local font and google font`() { execute(".font main:{$localPath} heading:{GoogleFonts:Roboto} code:{GoogleFonts:Source Code Pro}") { val fontConfiguration = documentInfo.layout.fonts.first() val media = mediaStorage.resolve(localPath) assertIs<FontFamily.Media>(fontConfiguration.mainFamily) assertIs<FontFamily.GoogleFont>(fontConfiguration.headingFamily) assertIs<FontFamily.GoogleFont>(fontConfiguration.codeFamily) assertNotNull(media) } } @Test fun `multiple font configurations`() { execute( """ .font main:{GoogleFonts:Roboto} .font main:{$localPath} heading:{GoogleFonts:Source Sans Pro} .font code:{GoogleFonts:Source Code Pro} """.trimIndent(), ) { val fontConfigurations = documentInfo.layout.fonts assertEquals(3, fontConfigurations.size) assertIs<FontFamily.GoogleFont>(fontConfigurations[0].mainFamily) assertIs<FontFamily.Media>(fontConfigurations[1].mainFamily) assertIs<FontFamily.GoogleFont>(fontConfigurations[1].headingFamily) assertIs<FontFamily.GoogleFont>(fontConfigurations[2].codeFamily) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FootnoteTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for footnote definitions and references. */ class FootnoteTest { private fun referenceHtml( label: String, formattedIndex: String, ) = "<sup class=\"footnote-reference footnote-label\" data-definition=\"__footnote-$label\">" + "<a href=\"#__footnote-$label\">$formattedIndex</a>" + "</sup>" private fun definitionHtml( label: String, index: Int, formattedIndex: String = (index + 1).toString(), content: String, ) = "<span class=\"footnote-definition\" id=\"__footnote-$label\" data-footnote-index=\"$index\">" + "<sup class=\"footnote-label\">$formattedIndex</sup>" + "<span>$content</span>" + "</span>" @Test fun `fallback reference`() { execute( """ Hello[^1] """.trimIndent(), ) { assertEquals("<p>Hello[^1]</p>", it) } } @Test fun `reference after definition`() { execute( """ Hello[^1] [^1]: This is a *footnote*. """.trimIndent(), ) { assertEquals( "<p>Hello" + referenceHtml("1", "1") + "</p>" + definitionHtml( label = "1", index = 0, content = "This is a <em>footnote</em>.", ), it, ) } } @Test fun `reference before definition`() { execute( """ [^1]: This is a *footnote*. Hello[^1] """.trimIndent(), ) { assertEquals( definitionHtml( label = "1", index = 0, content = "This is a <em>footnote</em>.", ) + "<p>Hello" + referenceHtml("1", "1") + "</p>", it, ) } } @Test fun `multiline definition`() { execute( """ x[^long] [^long]: a multiline note """.trimIndent(), ) { assertEquals( "<p>x" + referenceHtml("long", "1") + "</p>" + definitionHtml( label = "long", index = 0, content = "a multiline<br />note", ), it, ) } } @Test fun `multiple references to same definition`() { execute( """ x[^1] and y[^1] [^1]: note """.trimIndent(), ) { assertEquals( "<p>x" + referenceHtml("1", "1") + " and y" + referenceHtml("1", "1") + "</p>" + definitionHtml( label = "1", index = 0, content = "note", ), it, ) } } @Test fun `single references to different definitions`() { execute( """ x[^1] and y[^2] and z[^3] [^1]: note [^2]: another note [^3]: yet another note """.trimIndent(), ) { assertEquals( "<p>x" + referenceHtml("1", "1") + " and y" + referenceHtml("2", "2") + " and z" + referenceHtml("3", "3") + "</p>" + definitionHtml( label = "1", index = 0, content = "note", ) + definitionHtml( label = "2", index = 1, content = "another note", ) + definitionHtml( label = "3", index = 2, content = "yet another note", ), it, ) } } @Test fun `multiple references to different definitions`() { execute( """ a[^1] and b[^1] and c[^2] and d[^1] and e[^3] and f[^2] [^1]: note [^2]: another note [^3]: yet another note """.trimIndent(), ) { assertEquals( "<p>a" + referenceHtml("1", "1") + " and b" + referenceHtml("1", "1") + " and c" + referenceHtml("2", "2") + " and d" + referenceHtml("1", "1") + " and e" + referenceHtml("3", "3") + " and f" + referenceHtml("2", "2") + "</p>" + definitionHtml( label = "1", index = 0, content = "note", ) + definitionHtml( label = "2", index = 1, content = "another note", ) + definitionHtml( label = "3", index = 2, content = "yet another note", ), it, ) } } @Test fun `all-in-one named definition and reference`() { execute("a[^x] and b[^x: first definition] and c[^y: second definition] and d[^x]") { assertEquals( "<p>a" + referenceHtml("x", "1") + " and b" + referenceHtml("x", "1") + definitionHtml( label = "x", index = 0, content = "first definition", ) + " and c" + referenceHtml("y", "2") + definitionHtml( label = "y", index = 1, content = "second definition", ) + " and d" + referenceHtml("x", "1") + "</p>", it, ) } } @Test fun `all-in-one anonymous definition and reference`() { var uuid = 0 val firstUuid = "2" val secondUuid = "4" execute( "a[^: anonymous definition] and b[^: *another* anonymous definition]", options = DEFAULT_OPTIONS.copy(uuidSupplier = { (++uuid * 2).toString() }), ) { assertEquals( "<p>a" + referenceHtml(firstUuid, "1") + definitionHtml( label = firstUuid, index = 0, content = "anonymous definition", ) + " and b" + referenceHtml(secondUuid, "2") + definitionHtml( label = secondUuid, index = 1, content = "<em>another</em> anonymous definition", ) + "</p>", it, ) } } @Test fun numbered() { execute( """ .numbering - footnotes: i a[^x] and b[^y] [^x]: note x [^y]: note y """.trimIndent(), ) { assertEquals( "<p>a" + referenceHtml("x", "i") + " and b" + referenceHtml("y", "ii") + "</p>" + definitionHtml( label = "x", index = 0, formattedIndex = "i", content = "note x", ) + definitionHtml( label = "y", index = 1, formattedIndex = "ii", content = "note y", ), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FormatPageNumberTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals class FormatPageNumberTest { @Test fun `format page number`() { execute( """ .doctype {paged} .doclang {english} .formatpagenumber format:{i} """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<div class=\"page-number-formatter\" data-format=\"i\" data-hidden=\"\"></div>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FunctionCallChainingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for function call chaining. */ class FunctionCallChainingTest { @Test fun math() { execute(".sum {2} {1}::subtract {1}") { assertEquals("<p>2</p>", it) } execute(".sum {2} {1}::subtract {4}::multiply {2}") { assertEquals("<p>-2</p>", it) } } @Test fun functional() { execute(".sum {2} {4}::subtract {1}::takeif {@lambda x: .iseven {.x}}::otherwise {Odd number!}") { assertEquals("<p>Odd number!</p>", it) } execute(".sum {2} {4}::subtract {1}::takeif {@lambda x: .iseven {.x}::not}::otherwise {Odd number!}") { assertEquals("<p>5</p>", it) } } @Test fun wrapped() { execute("abc{.sum {2} {4}::subtract {1}}def") { assertEquals("<p>abc5def</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/FunctionCallTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.attributes.presence.hasCode import com.quarkdown.core.ast.attributes.presence.hasMath import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** * Tests for function calls. */ class FunctionCallTest { @Test fun functions() { execute(".sum {3} {4}") { assertEquals("<p>7</p>", it) } execute(".multiply {3} by:{6}") { assertEquals("<p>18</p>", it) } execute( """ .divide { .cos {.pi} } by:{ .sin { 1 } } """.trimIndent(), ) { assertEquals("<p>-1.1883951</p>", it) } execute("$ 4 - 2 = $ .subtract {4} {2}") { assertEquals("<p><formula>4 - 2 =</formula> 2</p>", it) assertTrue(attributes.hasMath) } execute("***result***: .sum {3} {.multiply {4} {2}}") { assertEquals("<p><em><strong>result</strong></em>: 11</p>", it) assertFalse(attributes.hasMath) } execute(".code\n .read {code.txt}") { assertEquals( "<pre><code>Line 1\nLine 2\n\nLine 3</code></pre>", it, ) assertTrue(attributes.hasCode) } } @Test fun `adjacent inline function calls`() { execute(".sum {1} {2} .sum {3} {4}") { assertEquals("<p>3 7</p>", it) } execute(".sum {1} {2}") { assertEquals("<p>3</p>", it) } execute(".sum {1} {2} result") { assertEquals("<p>3 result</p>", it) } execute(".sum {1} {2} abc .sum {3} {4}") { assertEquals("<p>3 abc 7</p>", it) } execute(".sum {1} {2} abc {}") { assertEquals("<p>3 abc {}</p>", it) } } @Test fun `wrapped block function call`() { execute("{.sum {1} {2}}") { assertEquals("<p>3</p>", it) } execute( """ .if {yes} {.sum {1} {2}}jkl """.trimIndent(), ) { assertEquals("<p>3jkl</p>", it) } } @Test fun `wrapped inline function call (loose)`() { execute("hello {.sum {1} {2}} hello") { assertEquals("<p>hello 3 hello</p>", it) } } @Test fun `wrapped inline function call (tight)`() { execute("hello{.sum {1} {2}}hello") { assertEquals("<p>hello3hello</p>", it) } } @Test fun `escaped wrap`() { execute("hello\\{.sum {1} {2}}hello") { assertEquals("<p>hello{3}hello</p>", it) } } @Test fun `malformed wrap`() { execute("{.sum {1} {2} .sum {1} {2}}") { assertEquals("<p>{3 3}</p>", it) } execute( """ .if {yes} abc{.uppercase {def} .uppercase {ghi}}jkl """.trimIndent(), ) { assertEquals("<p>abc{DEF GHI}jkl</p>", it) } } @Test fun `incomplete wrap`() { execute("hello{.sum {1} {2}") { assertEquals("<p>hello{3</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/HeadingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.function.error.FunctionCallRuntimeException import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs /** * Tests for headings, both Markdown-based and via the `.heading` primitive function. */ class HeadingTest { @Test fun `markdown heading`() { execute("# Title") { assertEquals("<h1 class=\"page-break\">Title</h1>", it) } execute("## Ti*tl*e") { assertEquals("<h2>Ti<em>tl</em>e</h2>", it) } execute("#### .sum {3} {2}") { assertEquals("<h4>5</h4>", it) } execute("###### .text {Hello, **world**} size:{tiny}") { assertEquals("<h6><span class=\"size-tiny\">Hello, <strong>world</strong></span></h6>", it) } } @Test fun `decorative heading`() { execute("#! Title") { assertEquals("<h1 data-decorative=\"\">Title</h1>", it) } } @Test fun `default id`() { execute( "## Title", options = DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals("<h2 id=\"title\">Title</h2>", it) } } @Test fun `custom id`() { execute("## Title {#custom-id}") { assertEquals("<h2 id=\"custom-id\">Title</h2>", it) } } @Test fun `auto page break`() { execute( """ .autopagebreak maxdepth:{4} ## A ### B ##### C """.trimIndent(), ) { assertEquals( "<h2 class=\"page-break\">A</h2>" + "<h3 class=\"page-break\">B</h3>" + "<h5>C</h5>", it, ) } execute( """ .noautopagebreak # A """.trimIndent(), ) { assertEquals("<h1>A</h1>", it) } } @Test fun `heading primitive`() { execute(".heading {Hello} depth:{1}") { assertEquals("<h1 class=\"page-break\">Hello</h1>", it) } execute(".heading {Hello} depth:{3}") { assertEquals("<h3>Hello</h3>", it) } } @Test fun `heading primitive with custom id`() { execute(".heading {Hello} depth:{2} ref:{my-id}") { assertEquals("<h2 id=\"my-id\">Hello</h2>", it) } } @Test fun `heading primitive unnumbered`() { execute(".heading {Hello} depth:{2} numbered:{no}") { assertEquals("<h2>Hello</h2>", it) } } @Test fun `heading primitive decorative`() { execute(".heading {Hello} depth:{2} numbered:{no} indexed:{no} breakpage:{no}") { assertEquals("<h2 data-decorative=\"\">Hello</h2>", it) } } @Test fun `heading primitive no page break`() { execute(".heading {Hello} depth:{1} breakpage:{no}") { assertEquals("<h1>Hello</h1>", it) } } @Test fun `heading primitive depth out of range`() { assertFailsWith<FunctionCallRuntimeException> { execute(".heading {Hello} depth:{0}") {} }.also { exception -> assertIs<IllegalArgumentException>(exception.cause) } assertFailsWith<FunctionCallRuntimeException> { execute(".heading {Hello} depth:{7}") {} }.also { exception -> assertIs<IllegalArgumentException>(exception.cause) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/HtmlOutputResourceTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.TextOutputArtifact import com.quarkdown.test.util.execute import com.quarkdown.test.util.getSubResources import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals /** * Tests for generation of HTML output resources. */ class HtmlOutputResourceTest { @Test fun `regular output with index, theme and script dirs`() { execute( source = "", outputResourceHook = { group -> val resources = getSubResources(group).map { it.name } assertContains(resources, "index") assertContains(resources, "theme") assertContains(resources, "script") }, ) {} } @Test fun `with media`() { execute( source = "![](img/icon.png)", enableMediaStorage = true, outputResourceHook = { group -> val resources = getSubResources(group).map { it.name } assertContains(resources, MEDIA_SUBDIRECTORY_NAME) }, ) {} } private fun getSearchIndexOutputResource(group: OutputResource?): TextOutputArtifact { val resources = getSubResources(group) return resources.filterIsInstance<TextOutputArtifact>().first { it.name == "search-index" } } private fun getSearchIndexInternalResource(name: String): String = javaClass .getResource("/data/search-index/$name.json")!! .readText() .let { Json.parseToJsonElement(it).toString() } @Test fun `with search index, no headings, no metadata`() { execute( """ .doctype {docs} [1](subdoc/simple-1.qd) [2](subdoc/simple-2.qd) """.trimIndent(), outputResourceHook = { group -> assertEquals( getSearchIndexInternalResource("search-index-no-headings-no-metadata"), getSearchIndexOutputResource(group).content, ) }, ) {} } @Test fun `with search index, no headings, with metadata`() { execute( """ .docname {Test} .doctype {docs} [1](subdoc/simple-1.qd) [2](subdoc/metadata.qd) """.trimIndent(), outputResourceHook = { group -> assertEquals( getSearchIndexInternalResource("search-index-no-headings-with-metadata"), getSearchIndexOutputResource(group).content, ) }, ) {} } @Test fun `with search index, with headings`() { execute( """ .docname {Test} .doctype {docs} [1](subdoc/headings-1.qd) [1](subdoc/headings-2.qd) """.trimIndent(), outputResourceHook = { group -> assertEquals( getSearchIndexInternalResource("search-index-with-headings"), getSearchIndexOutputResource(group).content, ) }, ) {} } @Test fun `with search index, with page margin`() { execute( """ .docname {Test} .doctype {docs} .pagemargin {righttop} This is page margin content that should not appear in the search index # Heading Text """.trimIndent(), outputResourceHook = { group -> assertEquals( getSearchIndexInternalResource("search-index-with-page-margin"), getSearchIndexOutputResource(group).content, ) }, ) {} } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/IOTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.DATA_FOLDER import com.quarkdown.test.util.execute import java.io.File import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for IO functions. */ class IOTest { @Test fun `read file`() { execute(".read {code.txt}") { assertEquals( "<p>Line 1\nLine 2\n\nLine 3</p>", it, ) } } @Test fun `list files`() { val files = File(DATA_FOLDER, "include").listFiles()!! execute(".listfiles {include} sortby:{name} order:{descending} fullpath:{no}") { assertTrue(it.startsWith("<ol>")) files.forEach { file -> assertContains(it, "<li><p>${file.name}</p></li>") } assertTrue(it.endsWith("</ol>")) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/InjectionTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for language injection functions. */ class InjectionTest { @Test fun html() { execute(".html\n\tHello, <b>world</b>!") { assertEquals("Hello, <b>world</b>!", it) } } @Test fun `block html`() { execute( """ Hello .html <p><sup>World</sup></p> """.trimIndent(), ) { assertEquals( "<p>Hello</p><p><sup>World</sup></p>", it, ) } } @Test fun `inline html`() { execute("Hello, .html {<sup>World</sup>}") { assertEquals( "<p>Hello, <sup>World</sup></p>", it, ) } } @Test fun css() { execute( """ .css .class { background: red; } """.trimIndent(), ) { assertEquals( "<style data-hidden=\"\">.class {\n background: red !important;\n}</style>", it, ) } } @Test fun `css from file`() { execute(".css {.read {css/style.css}}") { assertEquals( "<style data-hidden=\"\">body {\n background-color: orange !important;\n}</style>", it, ) } } @Test fun `css properties`() { execute( """ .cssproperties - background-color: blue - main-font-size: 16px """.trimIndent(), ) { assertEquals( "<style data-hidden=\"\">" + ":root { --qd-background-color: blue !important; --qd-main-font-size: 16px !important; }" + "</style>", it, ) } } @Test fun `css with custom class elements`() { execute( """ .container classname:{my-custom} Content Hi .text {content} classname:{my-custom} .css .my-custom { color: red; } """.trimIndent(), ) { assertEquals( "<div class=\"container my-custom\"><p>Content</p></div>" + "<p>Hi <span class=\"my-custom\">content</span></p>" + "<style data-hidden=\"\">.my-custom {\n color: red !important;\n}</style>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/IterableTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for iterables. */ class IterableTest { private val letters = """ .var {abc} - A - B - C """.trimIndent() private val numbers = """ .var {nums} - 1 - 2 - 3 - 4 """.trimIndent() @Test fun iterate() { execute( letters + """ .foreach {.abc} .1 """.trimIndent(), ) { assertEquals("<p>A</p><p>B</p><p>C</p>", it) } } @Test fun `iterate with transform`() { execute( letters + """ .foreach {.abc} .lowercase {.1} """.trimIndent(), ) { assertEquals("<p>a</p><p>b</p><p>c</p>", it) } } @Test fun `get at`() { execute("$letters.abc::getat {2}") { assertEquals("<p>B</p>", it) } } @Test fun `get at, out of bounds`() { execute("$letters.abc::getat {5}") { assertEquals("<p><span class=\"codespan-content\"><code>None</code></span></p>", it) } } @Test fun `get first`() { execute("$letters.first from:{.abc}") { assertEquals("<p>A</p>", it) } } @Test fun `get last`() { execute("$letters.last from:{.abc}") { assertEquals("<p>C</p>", it) } } @Test fun `get size`() { execute("$letters.size of:{.abc}") { assertEquals("<p>3</p>", it) } } @Test fun `iterate numbers`() { execute( numbers + """ .foreach {.nums} n: .pow {.n} to:{2} """.trimIndent(), ) { assertEquals("<p>1</p><p>4</p><p>9</p><p>16</p>", it) } } @Test fun `sum of numbers`() { execute("$numbers.nums::sumall") { assertEquals("<p>10</p>", it) } } @Test fun `average of numbers`() { execute("$numbers.nums::average") { assertEquals("<p>2.5</p>", it) } } @Test fun distinct() { execute( """ .var {abc} - A - B - A - A - B - C .abc::distinct::size """.trimIndent(), ) { assertEquals( "<p>3</p>", it, ) } } @Test fun reverse() { execute("$letters.foreach {.abc::reversed}\n\t.1") { assertEquals("<p>C</p><p>B</p><p>A</p>", it) } } @Test fun `sort by natural order`() { execute( """ .var {abc} - C - A - B .foreach {.abc::sorted} .1 """.trimIndent(), ) { assertEquals("<p>A</p><p>B</p><p>C</p>", it) } } @Test fun `sort by natural order, descending`() { execute( """ .var {abc} - 3 - 1 - 2 .foreach {.abc::sorted::reversed} .1 """.trimIndent(), ) { assertEquals("<p>3</p><p>2</p><p>1</p>", it) } } @Test fun `sort by property`() { execute( """ .var {dict} .dictionary - a: 3 - b: 1 - c: 2 .foreach {.dict::sorted by:{@lambda name value: .value}} name value: .name """.trimIndent(), ) { assertEquals("<p>b</p><p>c</p><p>a</p>", it) } } @Test fun group() { execute( """ .var {abc} - A - B - A - A - B - C .foreach {.abc::groupvalues} group: Group of .group::first of size .group::size """.trimIndent(), ) { assertEquals( "<p>Group of A of size 3</p>" + "<p>Group of B of size 2</p>" + "<p>Group of C of size 1</p>", it, ) } } @Test fun `handle pairs`() { execute( """ .var {p} {.pair {1} {2}} .sum {.first {.p}} {.second {.p}} """.trimIndent(), ) { assertEquals("<p>3</p>", it) } } @Test fun `iterate pairs`() { execute( """ .foreach {.pair {1} {2}} .1 """.trimIndent(), ) { assertEquals("<p>1</p><p>2</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/LayoutTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for elements and functions that compose complex layouts. */ class LayoutTest { @Test fun `node mapping`() { // Function is a block execute( """ ## Title .libexists {stdlib} """.trimIndent(), ) { assertEquals("<h2>Title</h2><p><input disabled=\"\" type=\"checkbox\" checked=\"\" /></p>", it) } // Function is inline execute( """ ## Title Text .libexists {stdlib} """.trimIndent(), ) { assertEquals("<h2>Title</h2><p>Text <input disabled=\"\" type=\"checkbox\" checked=\"\" /></p>", it) } } @Test fun stacks() { execute( """ .row Hello 1 Hello 2 Hello 3 """.trimIndent(), ) { assertEquals( "<div style=\"justify-content: flex-start; align-items: center;\" class=\"stack stack-row\">" + "<p>Hello 1\nHello 2</p><p>Hello 3</p>" + "</div>", it, ) } execute( """ .column alignment:{spacebetween} cross:{start} gap:{1cm} Hello 1 ## Hello 2 Hello 3 .box {Hello 4} type:{tip} Hello 5 """.trimIndent(), ) { assertEquals( "<div style=\"justify-content: space-between; align-items: flex-start; row-gap: 1.0cm;\" class=\"stack stack-column\">" + "<p>Hello 1</p>" + "<h2>Hello 2</h2>" + "<pre><code>Hello 3</code></pre>" + "<div class=\"box tip\"><header><h4>Hello 4</h4></header><div class=\"box-content\"><p>Hello 5</p></div></div>" + "</div>", it, ) } execute( """ .row alignment:{center} cross:{center} gap:{200px} .column cross:{end} ## Quarkdown A cool language .column gap:{1cm} .clip {circle} ![](img1.png) .clip {circle} ![](img2.png) .clip {circle} ![](img3.png) **[GitHub](https://github.com/iamgio/quarkdown)** """.trimIndent(), ) { assertEquals( "<div style=\"justify-content: center; align-items: center; column-gap: 200.0px;\" class=\"stack stack-row\">" + "<div style=\"justify-content: flex-start; align-items: flex-end;\" class=\"stack stack-column\">" + "<h2>Quarkdown</h2><p>A cool language</p>" + "</div>" + "<div style=\"justify-content: flex-start; align-items: center; row-gap: 1.0cm;\" class=\"stack stack-column\">" + "<div class=\"clip clip-circle\"><div class=\"container\">" + "<figure><img src=\"img1.png\" alt=\"\" /></figure>" + "</div></div>" + "<div class=\"clip clip-circle\"><div class=\"container\">" + "<figure><img src=\"img2.png\" alt=\"\" /></figure>" + "</div></div>" + "<div class=\"clip clip-circle\"><div class=\"container\">" + "<figure><img src=\"img3.png\" alt=\"\" /></figure>" + "</div></div>" + "</div>" + "<p><strong><a href=\"https://github.com/iamgio/quarkdown\">GitHub</a></strong></p>" + "</div>", it, ) } } @Test fun gridGap() { execute( """ .grid columns:{2} vgap:{1cm} hgap:{2cm} Hello 1 Hello 2 Hello 3 """.trimIndent(), ) { assertEquals( "<div" + " style=\"grid-template-columns: auto auto; justify-content: center;" + " align-items: center; row-gap: 1.0cm; column-gap: 2.0cm;\"" + " class=\"stack stack-grid\">" + "<p>Hello 1</p>" + "<p>Hello 2</p>" + "<p>Hello 3</p>" + "</div>", it, ) } execute( """ .grid columns:{2} gap:{1cm} hgap:{2cm} Hello 1 Hello 2 Hello 3 """.trimIndent(), ) { assertEquals( "<div" + " style=\"grid-template-columns: auto auto; justify-content: center;" + " align-items: center; row-gap: 1.0cm; column-gap: 2.0cm;\"" + " class=\"stack stack-grid\">" + "<p>Hello 1</p>" + "<p>Hello 2</p>" + "<p>Hello 3</p>" + "</div>", it, ) } } @Test fun float() { execute( """ Hello 1 .float {start} ![Quarkdown](img/icon.png) Hello 2 """.trimIndent(), ) { assertEquals( "<p>Hello 1</p>" + "<div class=\"container float\" style=\"float: inline-start;\">" + "<figure><img src=\"img/icon.png\" alt=\"Quarkdown\" /></figure>" + "</div>" + "<p>Hello 2</p>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/LinkTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for links, reference links, and reference images. */ class LinkTest { @Test fun link() { execute("This is a link: [link](https://example.com 'title')") { assertEquals("<p>This is a link: <a href=\"https://example.com\" title=\"title\">link</a></p>", it) } } @Test fun `text link`() { execute(".text {Hello} size:{tiny} url:{https://example.com}") { assertEquals("<a href=\"https://example.com\"><span class=\"size-tiny\">Hello</span></a>", it) } execute(".text {Hello} size:{tiny} url:{.concatenate {https://example} {\\.com}}") { assertEquals("<a href=\"https://example.com\"><span class=\"size-tiny\">Hello</span></a>", it) } } @Test fun `reference link`() { execute( """ [Link definition]: https://example.com **This is a link**: [link][Link definition] """.trimIndent(), ) { assertEquals( "<p><strong>This is a link</strong>: <a href=\"https://example.com\">link</a></p>", it, ) } } @Test fun `collapsed reference link`() { execute( """ [Link definition]: https://example.com ## _This is a link_: [Link definition] """.trimIndent(), ) { assertEquals( "<h2><em>This is a link</em>: <a href=\"https://example.com\">Link definition</a></h2>", it, ) } } @Test fun `unresolved reference link`() { execute( """ This link does not exist: [link][Link definition] """.trimIndent(), ) { assertEquals("<p>This link does not exist: [link][Link definition]</p>", it) } } @Test fun `reference link with title`() { execute( """ [ref]: https://example.com "Title" [ref] """.trimIndent(), ) { assertEquals( "<p><a href=\"https://example.com\" title=\"Title\">ref</a></p>", it, ) } } @Test fun `multiple reference links to same definition`() { execute( """ [ref]: https://example.com [A][ref] and [B][ref] """.trimIndent(), ) { assertEquals( "<p><a href=\"https://example.com\">A</a> and <a href=\"https://example.com\">B</a></p>", it, ) } } @Test fun `reference image`() { execute( """ [Alt text]: https://example.com/image.png ![Alt text][Alt text] """.trimIndent(), ) { assertEquals("<p><img src=\"https://example.com/image.png\" alt=\"Alt text\" /></p>", it) } } @Test fun `reference image in heading`() { execute( """ [Alt text]: https://example.com/image.png ## ![Alt text][Alt text] """.trimIndent(), ) { assertEquals("<h2><img src=\"https://example.com/image.png\" alt=\"Alt text\" /></h2>", it) } } @Test fun `unresolved reference image`() { execute( """ This image does not exist: ![Alt text][Alt text] """.trimIndent(), ) { assertEquals("<p>This image does not exist: ![Alt text][Alt text]</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/LocalizationTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFalse /** * Tests for localization features. */ class LocalizationTest { @Test fun `new localization table`() { execute( """ .doclang {english} .localization {mytable} - English - morning: Good morning - evening: Good evening - Italian - morning: Buongiorno - evening: Buonasera > .localize {mytable:morning}. """.trimIndent(), ) { assertEquals("<blockquote><p>Good morning.</p></blockquote>", it) } } @Test fun `localization from function`() { execute( """ .doclang {italian} .localization {mytable} - English - theorem: Theorem - Italian - theorem: Teorema .function {theorem} **.localize {mytable:theorem}.** .theorem Test """.trimIndent(), ) { assertEquals("<p><strong>Teorema.</strong> Test</p>", it) } } @Test fun `missing localization entry`() { assertFails { execute( """ .doclang {english} .localization {mytable} - English - morning: Good morning - evening: Good evening - Italian - morning: Buongiorno - evening: Buonasera > .localize {mytable:afternoon}. """.trimIndent(), ) {} } } @Test fun `stdlib localization`() { execute( """ .doclang {english} .localize {std:warning} """.trimIndent(), ) { assertEquals("<p>Warning</p>", it) } } @Test fun `duplicate table error`() { assertFails { execute( """ .localization {mytable} - English - morning: Good morning - Italian - morning: Buongiorno .localization {mytable} - English - evening: Good evening - Italian - evening: Buonasera """.trimIndent(), ) {} } } @Test fun `localization table merge`() { execute( """ .doclang {english} .localization {mytable} - English - morning: Good morning - Italian - morning: Buongiorno .localization {mytable} merge:{yes} - English - evening: Good evening - Italian - evening: Buonasera .localize {mytable:morning}, .localize {mytable:evening}. """.trimIndent(), ) { assertEquals("<p>Good morning, Good evening.</p>", it) } } @Test fun `stdlib localization table merge`() { execute( """ .doclang {fr-CA} .box type:{warning} Test """.trimIndent(), ) { assertFalse("<h4>Avertissement</h4>" in it) } execute( """ .doclang {fr-CA} .localization {std} merge:{yes} - fr-CA - warning: Avertissement .box type:{warning} Test """.trimIndent(), ) { assertContains(it, "<h4>Avertissement</h4>") } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/MathTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for math nodes and TeX integration. */ class MathTest { @Test fun `inline math`() { execute("Hello $ \\frac {x} {2} $") { assertEquals( "<p>Hello <formula>\\frac {x} {2}</formula></p>", it, ) } } @Test fun `multiple inline math in the same paragraph`() { execute("$ \\frac {x} {2} $ and $ \\sqrt {x + 1} $") { assertEquals( "<p><formula>\\frac {x} {2}</formula> and <formula>\\sqrt {x + 1}</formula></p>", it, ) } } @Test fun `one-line block math`() { execute("$ \\frac {x} {2} $") { assertEquals( "<formula data-block=\"\">\\frac {x} {2}</formula>", it, ) assertEquals(0, documentInfo.tex.macros.size) } } @Test fun `one-line block math with inner dollar sign`() { execute("$ \\frac {x} {2}$ $") { assertEquals( "<formula data-block=\"\">\\frac {x} {2}$</formula>", it, ) assertEquals(0, documentInfo.tex.macros.size) } } @Test fun `multiline block math`() { execute( """ $$$ f(x) = \begin{cases} 1 & \text{if } x > 0 \\ 0 & \text{otherwise} \end{cases} $$$ """.trimIndent(), ) { assertEquals( """ <formula data-block="">f(x) = \begin{cases} 1 & \text{if } x > 0 \\ 0 & \text{otherwise} \end{cases}</formula> """.trimIndent(), it, ) } } @Test fun `custom macro`() { execute( """ .texmacro {\hello} Hello \textit {world} $ \hello $ """.trimIndent(), ) { assertEquals(1, documentInfo.tex.macros.size) val macro = documentInfo.tex.macros["\\hello"] assertEquals("Hello \\textit {world}", macro) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/MediaStorageTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.INDEX import com.quarkdown.test.util.execute import com.quarkdown.test.util.getMediaResources import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNotNull import kotlin.test.assertTrue /** * Tests for the media storage system. */ class MediaStorageTest { @Test fun `local media, no media storage`() { execute( """ This is the Quarkdown logo: ![Quarkdown](img/icon.png). """.trimIndent(), enableMediaStorage = false, outputResourceHook = { group -> assertFails { getMediaResources(group) } }, ) { assertEquals("<p>This is the Quarkdown logo: <img src=\"img/icon.png\" alt=\"Quarkdown\" />.</p>", it) assertEquals(0, mediaStorage.all.size) } } @Test fun `local media, with media storage`() { execute( """ This is the Quarkdown logo: ![Quarkdown](img/icon.png). """.trimIndent(), enableMediaStorage = true, outputResourceHook = { group -> assertTrue(getMediaResources(group).single().name.startsWith("icon")) }, ) { assertEquals("<p>This is the Quarkdown logo: <img src=\"media/icon", it.toString().substringBefore("@")) // The file name is "media/icon-[encoded].png" assertEquals("\" alt=\"Quarkdown\" />.</p>", it.toString().substringAfter(".png")) } } @Test fun `remote media, with media storage`() { execute( """ .container ![Icon](https://raw.githubusercontent.com/iamgio/quarkdown/project-files/images/ticon-light.svg "The Quarkdown icon") ![Banner](https://raw.githubusercontent.com/iamgio/quarkdown/project-files/images/tbanner-light.svg) """.trimIndent(), options = DEFAULT_OPTIONS.copy(enableRemoteMediaStorage = true), enableMediaStorage = true, outputResourceHook = { group -> assertEquals( "https-raw.githubusercontent.com-iamgio-quarkdown-project-files-images-ticon-light.svg", getMediaResources(group).single { "ticon" in it.name }.name, ) }, ) { assertEquals( "<div class=\"container\">" + "<figure>" + "<img src=\"media/https-raw.githubusercontent.com-iamgio-quarkdown-project-files-images-ticon-light.svg\" " + "alt=\"Icon\" title=\"The Quarkdown icon\" />" + "<figcaption class=\"caption-bottom\">The Quarkdown icon</figcaption>" + "</figure>" + "<figure>" + "<img src=\"media/https-raw.githubusercontent.com-iamgio-quarkdown-project-files-images-tbanner-light.svg\" " + "alt=\"Banner\" />" + "</figure>" + "</div>", it, ) assertEquals(2, mediaStorage.all.size) } } @Test fun `remote and local media, with local media storage only`() { execute( """ ![Banner](https://raw.githubusercontent.com/iamgio/quarkdown/project-files/images/tbanner-light.svg) ![Quarkdown](img/icon.png) """.trimIndent(), options = DEFAULT_OPTIONS.copy(enableRemoteMediaStorage = false), enableMediaStorage = true, outputResourceHook = { group -> assertTrue(getMediaResources(group).singleOrNull { it.name.startsWith("icon") } != null) assertFails { getMediaResources(group).single { it.name.startsWith("tbanner") } } }, ) { assertEquals( "<p>" + "<img src=\"https://raw.githubusercontent.com/iamgio/quarkdown/project-files/images/tbanner-light.svg\" " + "alt=\"Banner\" /><br /><img src=\"media/", it.toString().substringBefore("icon@"), ) assertEquals(1, mediaStorage.all.size) } } @Test fun `remote and local media, with media storage`() { execute( """ [Banner]: https://raw.githubusercontent.com/iamgio/quarkdown/project-files/images/tbanner-light.svg ![Banner] """.trimIndent(), options = DEFAULT_OPTIONS.copy(enableRemoteMediaStorage = true), enableMediaStorage = true, ) { assertEquals( "<p><img src=\"media/https-raw.githubusercontent.com-iamgio-quarkdown-project-files-images-tbanner-light.svg\" " + "alt=\"Banner\" /></p>", it, ) } } @Test fun `media from scope context`() { execute( """ .if {yes} ![Quarkdown](img/icon.png) """.trimIndent(), enableMediaStorage = true, outputResourceHook = { group -> assertTrue(getMediaResources(group).single().name.startsWith("icon")) }, ) { assertEquals("<figure><img src=\"media/icon", it.toString().substringBefore("@")) } } @Test fun `subdocument should have its own media storage`() { execute( source = "[1](subdoc/media-storage.qd)", enableMediaStorage = true, outputResourceHook = { group -> assertFails { getMediaResources(group) } // Root has no media assertNotNull(getMediaResources(group, "media-storage").singleOrNull { it.name.startsWith("icon") }) }, ) { if (subdocument == Subdocument.Root) { assertEquals(0, mediaStorage.all.size) } else { assertEquals(1, mediaStorage.all.size) } } } @Test fun `same media from root and subdocument should be separated`() { execute( source = "![icon](img/icon.png)\n\n[1](subdoc/media-storage.qd)", enableMediaStorage = true, outputResourceHook = { group -> assertNotNull(getMediaResources(group).singleOrNull { it.name.startsWith("icon") }) assertNotNull(getMediaResources(group, "media-storage").singleOrNull { it.name.startsWith("icon") }) }, ) { assertEquals(1, mediaStorage.all.size) assertContains(it, "<img src=\"media/icon") } } @Test fun `subdocument name clashing with media directory, with no root media`() { execute( source = "[1](subdoc/media.qd)", enableMediaStorage = true, outputResourceHook = { group -> assertNotNull(getMediaResources(group).singleOrNull { it.name == INDEX }) // index.html is the only file in media/ assertNotNull(getMediaResources(group, "media").singleOrNull { it.name.startsWith("icon") }) }, ) {} } @Test fun `subdocument name clashing with media directory, with root media`() { execute( source = "![icon](img/icon.png)\n\n[1](subdoc/media.qd)", enableMediaStorage = true, outputResourceHook = { group -> val mediaGroups = (group as OutputResourceGroup) .resources .filter { it.name == MEDIA_SUBDIRECTORY_NAME } .filterIsInstance<OutputResourceGroup>() assertEquals(2, mediaGroups.size) // Two 'media' subdocuments: root's media and 'media' subdocument val flattened = mediaGroups.flatMap { it.resources } assertTrue(flattened.any { it.name == INDEX }) // index.html is part of the 'media' subdocument assertTrue(flattened.any { it.name.startsWith("icon") }) // root media assertNotNull( ( flattened.first { it.name == MEDIA_SUBDIRECTORY_NAME } as OutputResourceGroup ).resources.singleOrNull { it.name.startsWith("icon") }, ) // 'media' subdocument media }, ) {} } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/MermaidTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.attributes.presence.hasMermaidDiagram import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue private const val MERMAID_OPEN = "<figure><pre class=\"mermaid fill-height\">" private const val MERMAID_CLOSE = "</pre></figure>" /** * Tests for Mermaid diagrams. */ class MermaidTest { private fun String.escape() = replace("<", "&lt;").replace(">", "&gt;") @Test fun mermaid() { execute( """ .mermaid graph TD A-->B A-->C """.trimIndent(), ) { assertEquals( MERMAID_OPEN + "graph TD\n A-->B\n A-->C".escape() + MERMAID_CLOSE, it, ) assertTrue(attributes.hasMermaidDiagram) } } @Test fun `mermaid with caption`() { execute( """ .mermaid caption:{My graph} graph TD A-->B A-->C """.trimIndent(), ) { assertEquals( MERMAID_OPEN + "graph TD\n A-->B\n A-->C".escape() + "</pre><figcaption class=\"caption-bottom\">My graph</figcaption></figure>", it, ) assertTrue(attributes.hasMermaidDiagram) } } @Test fun `mermaid from file`() { execute( """ .mermaid caption:{My graph} .read {mermaid/class.mmd} """.trimIndent(), ) { assertEquals(it.lines().first(), MERMAID_OPEN + "classDiagram") assertEquals(it.lines()[1], " class Bank {") assertContains(it, "<figcaption class=\"caption-bottom\">My graph</figcaption></figure>") } } private fun String.expectedChart() = MERMAID_OPEN + trimIndent().escape().replace(" ", "\t") + "\n" + MERMAID_CLOSE @Test fun `xy chart`() { execute( """ .xychart - 5000 - 6000 - 7500 """.trimIndent(), ) { assertEquals( """ xychart-beta line [5000.0, 6000.0, 7500.0] """.expectedChart(), it, ) assertTrue(attributes.hasMermaidDiagram) } } @Test fun `xy chart with bars`() { execute( """ .xychart bars:{yes} - 5000 - 6000 - 7500 """.trimIndent(), ) { assertEquals( """ xychart-beta bar [5000.0, 6000.0, 7500.0] line [5000.0, 6000.0, 7500.0] """.expectedChart(), it, ) } } @Test fun `xy chart with named axis`() { execute( """ .xychart x:{Months} y:{Revenue} - 5000 - 6000 - 7500 """.trimIndent(), ) { assertEquals( """ xychart-beta x-axis &quot;Months&quot; y-axis &quot;Revenue&quot; line [5000.0, 6000.0, 7500.0] """.expectedChart(), it, ) } } @Test fun `xy chart with ranged and named y axis`() { execute( """ .xychart y:{Revenue} yrange:{2..8000} - 5000 - 6000 - 7500 """.trimIndent(), ) { assertEquals( """ xychart-beta y-axis &quot;Revenue&quot; 2 --> 8000 line [5000.0, 6000.0, 7500.0] """.expectedChart(), it, ) } } @Test fun `xy chart with open-ranged y axis`() { execute( """ .xychart yrange:{..} - 5000 - 6000 - 7500 """.trimIndent(), ) { assertEquals( """ xychart-beta y-axis 5000.0 --> 7500.0 line [5000.0, 6000.0, 7500.0] """.expectedChart(), it, ) } } @Test fun `xy chart with function call in data`() { execute( """ .var {x} {2} .xychart .repeat {3} .var {x} {.pow {.x} {2}} .x """.trimIndent(), ) { assertEquals( """ xychart-beta line [4.0, 16.0, 256.0] """.expectedChart(), it, ) } } @Test fun `xy chart with three lines`() { execute( """ .xychart - - 3 - 2 - 1 - - 1 - 2 - 3 - - 2 - 1 - 3 """.trimIndent(), ) { assertEquals( """ xychart-beta line [3.0, 2.0, 1.0] line [1.0, 2.0, 3.0] line [2.0, 1.0, 3.0] """.expectedChart(), it, ) } } @Test fun `xy chart with open-ranged x axis`() { execute( """ .xychart xrange:{1..} - | - 3 - 2 - | - 1 - 2 - 3 """.trimIndent(), ) { assertEquals( """ xychart-beta x-axis 1 --> 3.0 line [3.0, 2.0] line [1.0, 2.0, 3.0] """.expectedChart(), it, ) } } @Test fun `xy chart with two curves`() { execute( """ .xychart .repeat {3} .1::pow {2} .repeat {3} .1::sin::multiply {5}::round """.trimIndent(), ) { assertEquals( """ xychart-beta line [4.0, 5.0, 1.0] line [1.0, 4.0, 9.0] """.expectedChart(), it, ) } } @Test fun `xy chart from csv`() { execute( """ .xychart .tablecolumn {2} .csv {csv/people.csv} """.trimIndent(), ) { assertEquals( """ xychart-beta line [25.0, 32.0, 19.0] """.expectedChart(), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/NodesTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for Markdown nodes. * @see LinkTest for link and image tests */ class NodesTest { @Test fun images() { execute("Some image: ![Alt text](https://example.com/image.png)") { assertEquals("<p>Some image: <img src=\"https://example.com/image.png\" alt=\"Alt text\" /></p>", it) } execute("![Alt text](https://example.com/image.png)") { assertEquals("<figure><img src=\"https://example.com/image.png\" alt=\"Alt text\" /></figure>", it) } execute("![Alt text](https://example.com/image.png 'Title')") { assertEquals( "<figure>" + "<img src=\"https://example.com/image.png\" alt=\"Alt text\" title=\"Title\" />" + "<figcaption class=\"caption-bottom\">Title</figcaption>" + "</figure>", it, ) } execute("Sized image: !(20x_)[Alt text](https://example.com/image.png)") { assertEquals( "<p>Sized image: <img src=\"https://example.com/image.png\" alt=\"Alt text\" style=\"width: 20.0px;\" /></p>", it, ) } execute("!(2in*2.1cm)[Alt text](https://example.com/image.png)") { assertEquals( "<figure><img src=\"https://example.com/image.png\" alt=\"Alt text\" style=\"width: 2.0in; height: 2.1cm;\" /></figure>", it, ) } } @Test fun lists() { execute("- Item 1\n- Item 2\n - Item 2.1\n - Item 2.2\n- Item 3") { assertEquals( "<ul><li>Item 1</li><li>Item 2<ul><li>Item 2.1</li><li>Item 2.2</li></ul></li><li>Item 3</li></ul>", it, ) } execute("1. Item 1\n2. Item 2\n 1. Item 2.1\n 2. Item 2.2\n3. Item 3") { assertEquals( "<ol><li>Item 1</li><li>Item 2<ol><li>Item 2.1</li><li>Item 2.2</li></ol></li><li>Item 3</li></ol>", it, ) } execute("- [ ] Unchecked\n- [x] Checked") { assertEquals( "<ul><li class=\"task-list-item\"><input disabled=\"\" type=\"checkbox\" />Unchecked</li>" + "<li class=\"task-list-item\"><input disabled=\"\" type=\"checkbox\" checked=\"\" />Checked</li></ul>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/NonStrictErrorHandlingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.test.util.execute import org.junit.jupiter.api.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for non-strict error handling, where errors are reported in the output instead of throwing exceptions. */ class NonStrictErrorHandlingTest { @Test fun `at binding time`() { execute(".sum {a} {3}", errorHandler = BasePipelineErrorHandler()) { assertEquals( "<div class=\"box error\">" + "<header><h4>Error: sum</h4></header>" + "<div class=\"box-content\"><p>" + "Cannot call function sum" + "<span class=\"inline-collapse\" data-full-text=\"(Number a, Number b)\" " + "data-collapsed-text=\"(...)\" data-collapsed=\"false\">" + "(Number a, Number b)" + "</span>" + " with arguments " + "<span class=\"inline-collapse\" data-full-text=\"(a, 3)\" " + "data-collapsed-text=\"(...)\" data-collapsed=\"false\">" + "(a, 3)" + "</span>: <br />" + "<em>Not a numeric value: a</em>" + "</p>" + "<pre><code class=\"no-highlight nohljsln\">.sum {a} {3}</code></pre>" + "</div></div>", it, ) } } @Test fun `in nested expression`() { execute(".if {yes}\n\t.sum {a} {3}", errorHandler = BasePipelineErrorHandler()) { assertContains(it, "<h4>Error: sum</h4>") assertContains(it, ".sum {a} {3}") } } @Test fun `in nested expression, in nested content`() { execute(".if {yes}\n\t.row\n\t\t.sum {2} {1} {5}", errorHandler = BasePipelineErrorHandler()) { assertContains(it, "<h4>Error: sum</h4>") } } @Test fun `invalid value reference`() { execute(".if {yes}\n\t.column alignment:{x}\n\t\tHi", errorHandler = BasePipelineErrorHandler()) { assertContains(it, "<header><h4>Error: column</h4></header>") assertContains(it, "Cannot call function column") assertContains(it, "No such element 'x' among values [") assertContains(it, "<pre><code class=\"no-highlight nohljsln\">.column alignment:{x}\n\tHi</code></pre>") } } @Test fun `at runtime`() { execute(".csv {nonexistent}", errorHandler = BasePipelineErrorHandler()) { assertContains(it, "<h4>Error: csv</h4>") assertContains(it, "Cannot call function csv") assertContains(it, "with arguments") assertContains(it, "nonexistent does not exist") assertContains(it, ".csv {nonexistent}</code></pre>") } } @Test fun `multiple layers of nesting`() { execute( """ .container a .row alignment:{center} .if {invalid} b """.trimIndent(), errorHandler = BasePipelineErrorHandler(), ) { assertContains(it, "<div class=\"container\"><p>a</p>") assertContains(it, "<h4>Error: if</h4>") assertContains(it, "Cannot call function if") assertContains(it, ".if {invalid}") } } @Test fun `infinite recursion renders error box`() { execute( """ .function {myfunc} .myfunc .myfunc """.trimIndent(), errorHandler = BasePipelineErrorHandler(), ) { assertContains(it, "box error") // The error message depends on the platform: the call depth limit may be reached first, or a stack overflow may occur earlier. assertTrue("Maximum function call depth" in it || "Stack overflow" in it) } } @Test fun `long source snippet should be folded`() { execute( """ .sum {1} 1 2 3 4 5 6 7 8 9 10 11 """.trimIndent(), errorHandler = BasePipelineErrorHandler(), ) { assertContains(it, "9\n... (2 more lines)") } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/NumberingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.pipeline.error.BasePipelineErrorHandler import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for numbering of headings, figures, tables and other elements. */ class NumberingTest { @Test fun `no numbering`() { // Numbering is disabled by default. execute( """ .noautopagebreak # A ## A/1 # B ![](img.png '') """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>A</h1>" + "<h2>A/1</h2>" + "<h1>B</h1>" + "<figure><img src=\"img.png\" alt=\"\" title=\"\" /><figcaption class=\"caption-bottom\"></figcaption></figure>", it, ) } } @Test fun `heading numbering`() { execute( """ .noautopagebreak .numbering - headings: 1.1 # A # B # C """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h1 data-location=\"2\">B</h1>" + "<h1 data-location=\"3\">C</h1>", it, ) } execute( """ .noautopagebreak .numbering - headings: 1.1 # A ## A/1 # B # C ## C/1 ## C/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2 data-location=\"1.1\">A/1</h2>" + "<h1 data-location=\"2\">B</h1>" + "<h1 data-location=\"3\">C</h1>" + "<h2 data-location=\"3.1\">C/1</h2>" + "<h2 data-location=\"3.2\">C/2</h2>", it, ) } // Decorative headings are not numbered. execute( """ .noautopagebreak .numbering - headings: 1.1 # A ## A/1 #! Nope! # B # C ## C/1 ## C/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2 data-location=\"1.1\">A/1</h2>" + "<h1 data-decorative=\"\">Nope!</h1>" + "<h1 data-location=\"2\">B</h1>" + "<h1 data-location=\"3\">C</h1>" + "<h2 data-location=\"3.1\">C/1</h2>" + "<h2 data-location=\"3.2\">C/2</h2>", it, ) } // Roman numerals. execute( """ .noautopagebreak .numbering - headings: I::i # A ## A/1 # B # C ## C/1 ## C/2 # D """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"I\">A</h1>" + "<h2 data-location=\"I::i\">A/1</h2>" + "<h1 data-location=\"II\">B</h1>" + "<h1 data-location=\"III\">C</h1>" + "<h2 data-location=\"III::i\">C/1</h2>" + "<h2 data-location=\"III::ii\">C/2</h2>" + "<h1 data-location=\"IV\">D</h1>", it, ) } execute( """ .noautopagebreak .numbering - headings: A.a.1 # A ## A/1 ### A/1/1 ## A/2 # B ### B/0/1 # C ## C/1 ### C/1/1 ## C/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"A\">A</h1>" + "<h2 data-location=\"A.a\">A/1</h2>" + "<h3 data-location=\"A.a.1\">A/1/1</h3>" + "<h2 data-location=\"A.b\">A/2</h2>" + "<h1 data-location=\"B\">B</h1>" + "<h3 data-location=\"B.0.1\">B/0/1</h3>" + "<h1 data-location=\"C\">C</h1>" + "<h2 data-location=\"C.a\">C/1</h2>" + "<h3 data-location=\"C.a.1\">C/1/1</h3>" + "<h2 data-location=\"C.b\">C/2</h2>", it, ) } // Nesting levels that don't fit in the numbering format are ignored. execute( """ .noautopagebreak .numbering - headings: 1.1 # A ## A/1 ### A/1/1 # B ### B/1/1 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2 data-location=\"1.1\">A/1</h2>" + "<h3>A/1/1</h3>" + "<h1 data-location=\"2\">B</h1>" + "<h3>B/1/1</h3>", it, ) } } @Test fun `heading primitive unnumbered`() { execute( """ .noautopagebreak .numbering - headings: 1.1 # A ## A/1 .heading {Skipped} depth:{1} numbered:{no} # B # C ## C/1 ## C/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2 data-location=\"1.1\">A/1</h2>" + "<h1>Skipped</h1>" + "<h1 data-location=\"2\">B</h1>" + "<h1 data-location=\"3\">C</h1>" + "<h2 data-location=\"3.1\">C/1</h2>" + "<h2 data-location=\"3.2\">C/2</h2>", it, ) } // Unnumbered but still indexed in the ToC. execute( """ .noautopagebreak .numbering - headings: 1.1 # A .heading {Not numbered} depth:{2} numbered:{no} indexed:{yes} # B """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2>Not numbered</h2>" + "<h1 data-location=\"2\">B</h1>", it, ) } } @Test fun `default numbering`() { // Default numbering set by the document type. execute( """ .doctype {paged} .noautopagebreak # A ## A/1 # B # C ## C/1 ### C/1/1 ## C/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<h2 data-location=\"1.1\">A/1</h2>" + "<h1 data-location=\"2\">B</h1>" + "<h1 data-location=\"3\">C</h1>" + "<h2 data-location=\"3.1\">C/1</h2>" + "<h3 data-location=\"3.1.1\">C/1/1</h3>" + "<h2 data-location=\"3.2\">C/2</h2>", it, ) } // Disable default numbering. execute( """ .doctype {paged} .nonumbering .noautopagebreak # A ## A/1 # B ![](img.png "Caption") | A | B | C | |---|---|---| | D | E | F | '' """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>A</h1>" + "<h2>A/1</h2>" + "<h1>B</h1>" + "<figure><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\">Caption</figcaption></figure>" + "<table><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\"></caption></table>", it, ) } } @Test fun `figure and table numbering`() { execute( """ .noautopagebreak .numbering - headings: 1.1.1 - figures: 1.1 # A ![](img.png "Caption 1") ![](img.png "Caption 2") # B ![](img.png "") """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<figure id=\"figure-1.1\"><img src=\"img.png\" alt=\"\" title=\"Caption 1\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.1\">Caption 1</figcaption>" + "</figure>" + "<figure id=\"figure-1.2\"><img src=\"img.png\" alt=\"\" title=\"Caption 2\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.2\">Caption 2</figcaption>" + "</figure>" + "<h1 data-location=\"2\">B</h1>" + "<figure id=\"figure-2.1\"><img src=\"img.png\" alt=\"\" title=\"\" />" + "<figcaption class=\"caption-bottom\" data-location=\"2.1\"></figcaption>" + "</figure>", it, ) } execute( """ .noautopagebreak .numbering - headings: 1 - figures: 1.A.a - tables: 1.A.a ![](img.png "Caption") # A ![](img.png "Caption") | A | B | C | |---|---|---| | D | E | F | 'Table caption' ## A/1 ![](img.png "Caption") ### A/1/1 ![](img.png "Caption") | A | B | C | |---|---|---| | D | E | F | '' # B ![](img.png "Caption") ### B/0/1 ![](img.png "Caption") """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<figure id=\"figure-0.0.a\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"0.0.a\">Caption</figcaption>" + "</figure>" + "<h1 data-location=\"1\">A</h1>" + "<figure id=\"figure-1.0.a\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.0.a\">Caption</figcaption>" + "</figure>" + "<table id=\"table-1.0.a\"><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"1.0.a\">Table caption</caption></table>" + "<h2>A/1</h2>" + "<figure id=\"figure-1.A.a\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.A.a\">Caption</figcaption>" + "</figure>" + "<h3>A/1/1</h3>" + "<figure id=\"figure-1.A.b\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.A.b\">Caption</figcaption>" + "</figure>" + "<table id=\"table-1.A.a\"><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"1.A.a\"></caption></table>" + "<h1 data-location=\"2\">B</h1>" + "<figure id=\"figure-2.0.a\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"2.0.a\">Caption</figcaption>" + "</figure>" + "<h3>B/0/1</h3>" + "<figure id=\"figure-2.0.b\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"2.0.b\">Caption</figcaption>" + "</figure>", it, ) } } @Test fun `non-captioned figures are not numbered`() { // Non-captioned tables are numbered since v1.10. execute( """ .noautopagebreak .numbering - figures: 1.1 - tables: 1.1 # A ![](img.png) | A | B | C | |---|---|---| | D | E | F | ![](img.png "Caption") | A | B | C | |---|---|---| | D | E | F | 'Caption' """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>A</h1>" + "<figure><img src=\"img.png\" alt=\"\" /></figure>" + "<table id=\"table-1.1\"><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"1.1\"></caption></table>" + "<figure id=\"figure-1.1\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.1\">Caption</figcaption>" + "</figure>" + "<table id=\"table-1.2\"><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"1.2\">Caption</caption></table>", it, ) } } @Test fun `math numbering`() { // A reference ID is required for numbering equations. execute( """ .numbering - equations: (1) $ E=mc^2 $ {#my-id} $ E=mc^2 $ $$$ {#_} E=mc^2 $$$ """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<formula data-block=\"\" data-location=\"(1)\">E=mc^2</formula>" + "<formula data-block=\"\">E=mc^2</formula>" + "<formula data-block=\"\" data-location=\"(2)\">E=mc^2</formula>", it, ) } } @Test fun `code block numbering`() { execute( """ .noautopagebreak .numbering - code: 1.1 # A ```kotlin val a = 1 ``` ```python a = 1 ``` # B ``` val a = 1 ``` """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>A</h1>" + "<figure id=\"listing-1.1\"><pre><code class=\"language-kotlin\">val a = 1</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"1.1\"></figcaption></figure>" + "<figure id=\"listing-1.2\"><pre><code class=\"language-python\">a = 1</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"1.2\"></figcaption></figure>" + "<h1>B</h1><figure id=\"listing-2.1\"><pre><code>val a = 1</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"2.1\"></figcaption></figure>", it, ) } } @Test fun `numbering merging`() { execute( """ .noautopagebreak .numbering - figures: a .numbering - headings: 1.1 # A ![](img.png "") """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<figure id=\"figure-a\"><img src=\"img.png\" alt=\"\" title=\"\" />" + "<figcaption class=\"caption-bottom\" data-location=\"a\"></figcaption>" + "</figure>", it, ) } } @Test fun `numbering merging, from default numbering`() { execute( """ .doctype {paged} .noautopagebreak .numbering - figures: 1.a # A ![](img.png "") """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<figure id=\"figure-1.a\"><img src=\"img.png\" alt=\"\" title=\"\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.a\"></figcaption>" + "</figure>", it, ) } } @Test fun `numbering override, no merging`() { execute( """ .noautopagebreak .numbering - figures: a .numbering merge:{no} - headings: 1.1 # A ![](img.png "") """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<figure><img src=\"img.png\" alt=\"\" title=\"\" />" + "<figcaption class=\"caption-bottom\"></figcaption>" + "</figure>", it, ) } } @Test fun `mermaid diagram numbered as figure`() { execute( """ .noautopagebreak .numbering - headings: 1.1.1 - figures: 1.1 # A ![](img.png "Caption 1") .mermaid caption:{Caption 2} graph TD A-->B A-->C ![](img.png "Caption 3") .mermaid ref:{mermaid-diagram} graph TD A-->B A-->C """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1 data-location=\"1\">A</h1>" + "<figure id=\"figure-1.1\"><img src=\"img.png\" alt=\"\" title=\"Caption 1\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.1\">Caption 1</figcaption>" + "</figure>" + "<figure id=\"figure-1.2\">" + "<pre class=\"mermaid fill-height\">graph TD\n A--&gt;B\n A--&gt;C</pre>" + "<figcaption class=\"caption-bottom\" data-location=\"1.2\">Caption 2</figcaption>" + "</figure>" + "<figure id=\"figure-1.3\"><img src=\"img.png\" alt=\"\" title=\"Caption 3\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.3\">Caption 3</figcaption>" + "</figure>" + "<figure id=\"figure-1.4\">" + "<pre class=\"mermaid fill-height\">graph TD\n A--&gt;B\n A--&gt;C</pre>" + "<figcaption class=\"caption-bottom\" data-location=\"1.4\"></figcaption>" + "</figure>", it, ) } } @Test fun `mermaid diagram without caption and ref id is not numbered`() { execute( """ .numbering - figures: 1 .mermaid graph TD A-->B A-->C """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<figure>" + "<pre class=\"mermaid fill-height\">graph TD\n A--&gt;B\n A--&gt;C</pre>" + "</figure>", it, ) } } @Test fun `custom figure`() { execute( """ .doclang {en} .numbering - figures: 1 .figure {My caption.} Hello, world! """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<figure id=\"figure-1\"><p>Hello, world!</p>" + "<figcaption class=\"caption-bottom\" data-location=\"1\" data-localized-kind=\"Figure\">" + "My caption.</figcaption></figure>", it, ) } } @Test fun `localized numbering captions`() { execute( """ .noautopagebreak .doclang {italian} .numbering - headings: none - figures: 1.1 - tables: 1.a - code: a # A ![](img.png "Caption") | A | B | C | |---|---|---| | D | E | F | (Caption) ``` val a = 1 ``` """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>A</h1>" + "<figure id=\"figure-1.1\"><img src=\"img.png\" alt=\"\" title=\"Caption\" />" + "<figcaption class=\"caption-bottom\" data-location=\"1.1\" data-localized-kind=\"Figura\">" + "Caption</figcaption>" + "</figure>" + "<table id=\"table-1.a\"><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead>" + "<tbody><tr><td>D</td><td>E</td><td>F</td></tr></tbody>" + "<caption class=\"caption-bottom\" data-location=\"1.a\" data-localized-kind=\"Tabella\">" + "Caption</caption></table>" + "<figure id=\"listing-a\"><pre><code>val a = 1</code></pre>" + "<figcaption class=\"caption-bottom\" data-location=\"a\" data-localized-kind=\"Listato\">" + "</figcaption></figure>", it, ) } } @Test fun `custom numbering`() { execute( """ .noautopagebreak .numbering - key1: 1.1 - key2: A # 1 .numbered {key1} num: Hello, .num! .numbered {key1} num: Hello again, .num! .numbered {key2} num: Hi, .num! # 2 .numbered {key1} num: Hi, .num! .numbered {key2} Hey, .1! """.trimIndent(), DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<h1>1</h1>" + "<p>Hello, 1.1!</p>" + "<p>Hello again, 1.2!</p>" + "<p>Hi, A!</p>" + "<h1>2</h1>" + "<p>Hi, 2.1!</p>" + "<p>Hey, B!</p>", it, ) } } /** * To understand why this is a special case, see [com.quarkdown.core.ast.quarkdown.block.Numbered]'s documentation. */ @Test fun `custom numbering on node`() { execute( """ .numbering - key: 1 .numbered {key} num: .container Hello, .num """.trimIndent(), options = DEFAULT_OPTIONS.copy(enableLocationAwareness = true), errorHandler = BasePipelineErrorHandler(), ) { assertEquals( "<div class=\"container\">" + "<p>Hello, 1</p>" + "</div>", it, ) } } /** * To understand why this is a special case, see [com.quarkdown.core.ast.quarkdown.block.Numbered]'s documentation. */ @Test fun `error handling in custom numbering`() { execute( """ .numbering - key: 1 .numbered {key} .sum {1} {a} """.trimIndent(), errorHandler = BasePipelineErrorHandler(), ) { assertTrue(it.startsWith("<div class=\"box error\"><header><h4>Error: sum</h4>")) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/OptionalityTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.function.error.InvalidArgumentCountException import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertFalse /** * Tests for scripting capabilities. */ class OptionalityTest { @Test fun `optional arguments`() { execute( """ .function {greet} to?: Hello .to .greet {world} .greet """.trimIndent(), ) { assertEquals( "<p>Hello world</p>" + "<p>Hello None</p>", it, ) } execute( """ .function {greet} to from?: Hello .to from .from .greet {world} {John} .greet {world} """.trimIndent(), ) { assertEquals( "<p>Hello world from John</p>" + "<p>Hello world from None</p>", it, ) } execute( """ .function {greet} to? from?: Hello .to from .from .greet {world} {John} .greet {world} .greet """.trimIndent(), ) { assertEquals( "<p>Hello world from John</p>" + "<p>Hello world from None</p>" + "<p>Hello None from None</p>", it, ) } execute( """ .function {greet} to? from: Hello .to from .from .greet from:{John} .greet John """.trimIndent(), ) { assertEquals( "<p>Hello None from John</p>" + "<p>Hello None from John</p>", it, ) } assertFailsWith<InvalidArgumentCountException> { execute( """ .function {greet} to? from: Hello .to from .from .greet {world} """.trimIndent(), ) {} } assertFailsWith<InvalidArgumentCountException> { execute( """ .function {greet} to? from: Hello .to from .from .greet """.trimIndent(), ) {} } execute( """ .function {greet} to from?: Hello .to from .from::otherwise {.to} .greet {world} {John} .greet {world} """.trimIndent(), ) { assertEquals( "<p>Hello world from John</p>" + "<p>Hello world from world</p>", it, ) } } @Test fun optionality() { execute( """ .var {a} {0} .isnone {.a} .var {b} {.none} .isnone {.b} """.trimIndent(), ) { assertEquals( "<p><input disabled=\"\" type=\"checkbox\" /></p>" + "<p><input disabled=\"\" type=\"checkbox\" checked=\"\" /></p>", it, ) } execute( """ .function {greet} name: Hi! I am .name::otherwise {unnamed} .greet {John} .greet {.none} """.trimIndent(), ) { assertEquals( "<p>Hi! I am John</p>" + "<p>Hi! I am unnamed</p>", it, ) } execute( """ .var {num} {5} .num::takeif {@lambda x: .x::equals {5}} """.trimIndent(), ) { assertEquals( "<p>5</p>", it, ) } execute( """ .function {oddeven} num: .num::takeif {@lambda x: .x::iseven}::ifpresent {Even}::otherwise {Odd} .oddeven {5} .oddeven {4} """.trimIndent(), ) { assertEquals( "<p>Odd</p>" + "<p>Even</p>", it, ) } execute( """ .function {present} x: .x::ifpresent {@lambda Yes, .1 is present}::otherwise {Not present} .present {5} .present {.none} """.trimIndent(), ) { assertEquals( "<p>Yes, 5 is present</p>" + "<p>Not present</p>", it, ) } } @Test fun fallback() { // Dictionary. execute( """ .var {x} - a: 1 - b: 2 - c: 3 .get {b} from:{.x}::ifpresent {@lambda .1::sum {3}}::otherwise {No} .get {d} from:{.x}::ifpresent {@lambda .1::sum {3}}::otherwise {No} """.trimIndent(), ) { assertEquals("<p>5</p><p>No</p>", it) } // Collection. execute( """ .var {x} - 10 - 20 - 30 .x::getat {2}::ifpresent {@lambda .1::sum {3}}::otherwise {No} .x::getat {5}::ifpresent {@lambda .1::sum {3}}::otherwise {No} """.trimIndent(), ) { assertEquals("<p>23</p><p>No</p>", it) } } @Test fun `node as fallback`() { execute(".none::otherwise {.text {hi}}") { assertEquals("<span>hi</span>", it) } execute(".none::otherwise {a .text {hi}}") { assertEquals("<p>a <span>hi</span></p>", it) } execute(".none::otherwise {a .text {hi} b}") { assertEquals("<p>a <span>hi</span> b</p>", it) } execute(".none::otherwise {.text {hi} b}") { assertEquals("<p><span>hi</span> b</p>", it) } } @Test fun `none as null parameter in native function`() { execute( """ .row gap:{.none} Test """.trimIndent(), ) { assertFalse("gap" in it) } } @Test fun `none as null parameter in custom function`() { execute( """ .function {greet} name?: Hi! I am .name::otherwise {unnamed} .greet {.none} """.trimIndent(), ) { assertEquals( "<p>Hi! I am unnamed</p>", it, ) } } @Test fun `none as null parameter via operation`() { execute( """ .var {condition} {false} .var {gap} {1cm} .row gap:{.condition::takeif {.condition}} Test """.trimIndent(), ) { assertFalse("gap" in it) } } @Test fun `none as non-nullable parameter should fail typecheck`() { assertFails { execute(".sin {.none}") {} } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/PageBreakTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for page breaks, both via the `<<<` syntax and the `.pagebreak` primitive function. */ class PageBreakTest { @Test fun `syntax page break`() { execute( """ Hello <<< World """.trimIndent(), ) { assertEquals( "<p>Hello</p>" + "<div class=\"page-break\" data-hidden=\"\"></div>" + "<p>World</p>", it, ) } } @Test fun `page break primitive`() { execute( """ Hello .pagebreak World """.trimIndent(), ) { assertEquals( "<p>Hello</p>" + "<div class=\"page-break\" data-hidden=\"\"></div>" + "<p>World</p>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/PageMarginsTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for page margins rendering and initialization via [com.quarkdown.core.ast.quarkdown.invisible.PageMarginContentInitializer]. */ class PageMarginsTest { @Test fun `one fixed margin`() { execute( """ .pagemargin {topcenter} Content """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-top-center\" " + "data-on-left-page=\"top-center\" data-on-right-page=\"top-center\">" + "<p>Content</p>" + "</div>", it, ) } } @Test fun `multiple fixed margins`() { execute( """ .pagemargin {topleft} Left Top .pagemargin {bottomright} Right Bottom """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-top-left\" " + "data-on-left-page=\"top-left\" data-on-right-page=\"top-left\">" + "<p>Left Top</p>" + "</div>" + "<div class=\"page-margin-content page-margin-bottom-right\" " + "data-on-left-page=\"bottom-right\" data-on-right-page=\"bottom-right\">" + "<p>Right Bottom</p>" + "</div>", it, ) } } @Test fun `one mirror margin`() { execute( """ .pagemargin {topinside} Content """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-top-inside\" " + "data-on-left-page=\"top-right\" data-on-right-page=\"top-left\">" + "<p>Content</p>" + "</div>", it, ) } } @Test fun `multiple mirror margins`() { execute( """ .pagemargin {bottomoutside} Outside Bottom .pagemargin {topinside} Inside Top """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-bottom-outside\" " + "data-on-left-page=\"bottom-left\" data-on-right-page=\"bottom-right\">" + "<p>Outside Bottom</p>" + "</div>" + "<div class=\"page-margin-content page-margin-top-inside\" " + "data-on-left-page=\"top-right\" data-on-right-page=\"top-left\">" + "<p>Inside Top</p>" + "</div>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/PaperLibTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals /** * Tests for the `paper` library. */ class PaperLibTest { @Test fun definition() { execute( """ .doclang {english} .include {paper} .definition This is my definition. """.trimIndent(), loadableLibraries = setOf("paper"), ) { assertEquals("<p><strong>Definition.</strong> This is my definition.</p>", it) } } @Test fun `definition numbering`() { execute( """ .doclang {italian} .include {paper} .numbering - definitions: A .definition This is my definition. """.trimIndent(), loadableLibraries = setOf("paper"), options = DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals("<p><strong>Definizione A.</strong> This is my definition.</p>", it) } } @Test fun `theorem numbering`() { execute( """ .doclang {english} .include {paper} .numbering - theorems: 1.1 - proofs: 1.i .theorem This is my theorem. .proof And this is my proof. """.trimIndent(), loadableLibraries = setOf("paper"), options = DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p><strong>Theorem 0.1.</strong> This is my theorem.</p>" + "<p><strong>Proof 0.i.</strong> And this is my proof.</p>" + "<div class=\"container fullwidth\" style=\"justify-items: end; text-align: end;\">" + "<span class=\"size-huge\">∎</span>" + "</div>", it, ) } } @Test fun `proof customization`() { execute( """ .doclang {english} .include {paper} .numbering - proofs: a .paperblocksuffix {:} .proofend {#} .proof This is my proof. """.trimIndent(), loadableLibraries = setOf("paper"), options = DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p><strong>Proof a:</strong> This is my proof.</p>" + "<div class=\"container fullwidth\" style=\"justify-items: end; text-align: end;\">" + "<span class=\"size-huge\">#</span>" + "</div>", it, ) } } @Test fun `custom named paragraph`() { execute( """ .doclang {english} .include {paper} .namedparagraph {Problem} This is my problem. .namedparagraph {Solution} This is my solution. """.trimIndent(), loadableLibraries = setOf("paper"), ) { assertEquals( "<p><strong>Problem.</strong> This is my problem.</p>" + "<p><strong>Solution.</strong> This is my solution.</p>", it, ) } } @Test fun `numbered custom named paragraph`() { execute( """ .doclang {english} .include {paper} .numbering - problem: a .function {problem} content: .namedparagraph {Problem} tag:{problem} content:{.content} .problem This is my problem. .problem This is another problem. """.trimIndent(), loadableLibraries = setOf("paper"), options = DEFAULT_OPTIONS.copy(enableLocationAwareness = true), ) { assertEquals( "<p><strong>Problem a.</strong> This is my problem.</p>" + "<p><strong>Problem b.</strong> This is another problem.</p>", it, ) } } @Test fun `abstract`() { execute( """ .doclang {english} .include {paper} .abstract This is my abstract. .abstractalignment {end} .abstract This is my second abstract. """.trimIndent(), loadableLibraries = setOf("paper"), ) { assertContains( it, "<div class=\"container fullwidth\" style=\"justify-items: center; text-align: center;\">" + "<h4 data-decorative=\"\">Abstract</h4>" + "</div>", ) assertContains( it, "<div class=\"container fullwidth\" style=\"justify-items: end; text-align: end;\">" + "<h4 data-decorative=\"\">Abstract</h4>" + "</div>", ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/PersistentHeadingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** * Tests for persistent headings via [com.quarkdown.core.ast.quarkdown.inline.LastHeading]. */ class PersistentHeadingTest { @Test fun `unavailable in plain documents`() { assertFailsWith<InvalidFunctionCallException> { execute(".lastheading depth:{2}") { } } } @Test fun `in margin content`() { execute( """ .doctype {paged} .pagemargin {topcenter} .lastheading depth:{2} ## Heading """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-top-center\" " + "data-on-left-page=\"top-center\" data-on-right-page=\"top-center\">" + "<span class=\"last-heading\" data-depth=\"2\"></span>" + "</div>" + "<h2>Heading</h2>", it, ) } } @Test fun `with emphasis, in margin content`() { execute( """ .doctype {slides} .pagemargin {topcenter} *.lastheading depth:{2}* ## Heading """.trimIndent(), ) { assertEquals( "<div class=\"page-margin-content page-margin-top-center\" " + "data-on-left-page=\"top-center\" data-on-right-page=\"top-center\">" + "<p><em><span class=\"last-heading\" data-depth=\"2\"></span></em></p>" + "</div>" + "<h2>Heading</h2>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/ScriptingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.function.error.FunctionCallRuntimeException import com.quarkdown.core.function.error.InvalidArgumentCountException import com.quarkdown.core.function.error.MismatchingArgumentTypeException import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertFailsWith /** * Tests for scripting capabilities. */ class ScriptingTest { @Test fun `flow functions`() { execute(".if { .islower {2} than:{3} }\n **Text**") { assertEquals("<p><strong>Text</strong></p>", it) } execute(".if { .islower {3} than:{2} }\n **Text**") { assertEquals("", it) } execute(".ifnot { .islower {3} than:{2} }\n **Text**") { assertEquals("<p><strong>Text</strong></p>", it) } execute(".foreach {..3}\n **N:** .1") { assertEquals("<p><strong>N:</strong> 1</p><p><strong>N:</strong> 2</p><p><strong>N:</strong> 3</p>", it) } execute(".foreach {2..4}\n **N:** .1") { assertEquals("<p><strong>N:</strong> 2</p><p><strong>N:</strong> 3</p><p><strong>N:</strong> 4</p>", it) } execute(".foreach {.range from:{2} to:{4}}\n **N:** .1") { assertEquals("<p><strong>N:</strong> 2</p><p><strong>N:</strong> 3</p><p><strong>N:</strong> 4</p>", it) } execute( """ ## Title .foreach {..2} n: Hi .n """.trimIndent(), ) { assertEquals("<h2>Title</h2><p>Hi 1</p><p>Hi 2</p>", it) } execute( """ .foreach {.range to:{.sum {1} {1}}} ## Hello .1 .foreach {..1} **Hi**! """.trimIndent(), ) { assertEquals("<h2>Hello 1</h2><p><strong>Hi</strong>!</p><h2>Hello 2</h2><p><strong>Hi</strong>!</p>", it) } execute( """ .repeat {2} ## Hello .1 .repeat {1} **Hi**! """.trimIndent(), ) { assertEquals("<h2>Hello 1</h2><p><strong>Hi</strong>!</p><h2>Hello 2</h2><p><strong>Hi</strong>!</p>", it) } execute( """ .foreach {..2} .foreach {.range to:{2}} .foreach {..2} ## Title 2 # Title 1 Some text ### Title 3 """.trimIndent(), ) { assertEquals( ( "<h2>Title 2</h2><h2>Title 2</h2><h1 class=\"page-break\">Title 1</h1>".repeat( 2, ) + "<p>Some text</p>" ).repeat(2) + "<h3>Title 3</h3>", it, ) } execute(".function {hello}\n *Hello*!\n\n.hello") { assertEquals("<p><em>Hello</em>!</p>", it) } execute(".function {hello}\n target:\n **Hello** .target!\n\n.hello {world}") { assertEquals("<p><strong>Hello</strong> world!</p>", it) } assertFailsWith<InvalidArgumentCountException> { execute(".function {hello}\n target:\n `Hello` .target!\n\n.hello") {} } execute( """ .if {yes} .function {hello} name: Hello, *.name*! #### .hello {world} .hello {iamgio} """.trimIndent(), ) { assertEquals("<h4>Hello, <em>world</em>!</h4><p>Hello, <em>iamgio</em>!</p>", it) } execute( """ .var {a} {0} .a .var {a} {1} .a .a {2} .a """.trimIndent(), ) { assertEquals("<p>0</p><p>1</p><p>2</p>", it) } execute( """ .let {world} Hello, **.1**! """.trimIndent(), ) { assertEquals("<p>Hello, <strong>world</strong>!</p>", it) } execute( """ .foreach {..3} .let {.1} x: .sum {3} {.x} """.trimIndent(), ) { assertEquals("<p>4</p><p>5</p><p>6</p>", it) } execute( """ .let {code.txt} file: .let {.read {.file} {..2}} .code .1 """.trimIndent(), ) { assertEquals("<pre><code>Line 1\nLine 2</code></pre>", it) } execute( """ .let {X} x: .var {a} A .a """.trimIndent(), ) { assertEquals("<p>A</p>", it) } execute( """ .let {X} x: .var {a} .x .a """.trimIndent(), ) { assertEquals("<p>X</p>", it) } } @Test fun `function mutating external variable (via var overwrite)`() { execute( """ .var {num} {0} .function {increase} .var {num} {.num::sum {1}} .increase .num """.trimIndent(), ) { assertEquals("<p>1</p>", it) } } @Test fun `function mutating external variable (via subscript overwrite)`() { execute( """ .var {num} {0} .function {increase} .num {.num::sum {1}} .increase .num """.trimIndent(), ) { assertEquals("<p>1</p>", it) } } @Test fun `correct caller context propagation in defined function (2 levels)`() { execute( """ .function {a} x: .x .function {b} y: .a .y .b {hi} """.trimIndent(), ) { assertEquals("<p>hi</p>", it) } } @Test fun `correct caller context propagation in defined function (3 levels)`() { execute( """ .function {a} x: .c .x .function {b} y: .a .y .function {c} z: .z .b {hi} """.trimIndent(), ) { assertEquals("<p>hi</p>", it) } } @Test fun `type inference`() { execute( """ .function {x} arg: .if {.arg} Hi .ifnot {.arg} Hello .x {no} """.trimIndent(), ) { assertEquals("<p>Hello</p>", it) } execute( """ .var {x} {no} .if {.x} Hi .ifnot {.x} Hello """.trimIndent(), ) { assertEquals("<p>Hello</p>", it) } } @Test fun dictionaries() { val authors = """ .docauthors - John - from: USA - Maria - from: Italy """.trimIndent() execute( authors + """ .var {john} {.get {John} from:{.docauthors}} .get {from} from:{.john} """.trimIndent(), ) { assertEquals( "<p>USA</p>", it, ) } execute( authors + """ .foreach {.docauthors} An author is .first {.1}, from .get {from} from:{.second {.1}} """.trimIndent(), ) { assertEquals( "<p>An author is John, from USA</p>" + "<p>An author is Maria, from Italy</p>", it, ) } execute( """ .var {x} - a: 1 - b: 2 - c: 3 .get {b} from:{.x} """.trimIndent(), ) { assertEquals("<p>2</p>", it) } // Not found. execute( """ .var {x} - a: 1 - b: 2 - c: 3 .get {d} from:{.x} """.trimIndent(), ) { assertEquals("<p><span class=\"codespan-content\"><code>None</code></span></p>", it) } execute( """ .var {x} - a: - aa: 1 - ab: 2 - b: - ba: 3 - bb: 4 .get {ba} from:{.get {b} from:{.x}} """.trimIndent(), ) { assertEquals("<p>3</p>", it) } execute( """ .var {x} .dictionary - a - aa: 1 - ab: 2 - b - ba: 3 - bb: 4 .foreach {.x} .var {name} {.first {.1}} .var {dict} {.second {.1}} .var {key} {.concatenate {.name} {b}} .var {value} {.get {.key} {.dict}} .name, .value """.trimIndent(), ) { assertEquals("<p>a, 2</p><p>b, 4</p>", it) } } @Test fun destructuring() { execute( """ .var {x} .dictionary - a: 1 - b: 2 - c: 3 .foreach {.x} key value: **.key** has value **.value** """.trimIndent(), ) { assertEquals( "<p><strong>a</strong> has value <strong>1</strong></p>" + "<p><strong>b</strong> has value <strong>2</strong></p>" + "<p><strong>c</strong> has value <strong>3</strong></p>", it, ) } assertFails { execute( """ .var {x} .dictionary - a: 1 - b: 2 - c: 3 .foreach {.x} key value aaa: **.key** has value **.value** """.trimIndent(), ) {} } execute( """ .docauthors - Giorgio - email: gio@test.com - country: Italy - Mary - country: USA .foreach {.docauthors} name info: .name's country is .get {country} {.info} """.trimIndent(), ) { assertEquals( "<p>Giorgio&rsquo;s country is Italy</p>" + "<p>Mary&rsquo;s country is USA</p>", it, ) } } @Test fun math() { execute(".sum {1} {2}") { assertEquals("<p>3</p>", it) } execute(".sum {1} {2}::multiply by:{3}") { assertEquals("<p>9</p>", it) } execute(".sum {1} {2}::subtract {1}::multiply by:{3}::divide by:{3}") { assertEquals("<p>2</p>", it) } execute(".pi::truncate {2}") { assertEquals("<p>3.14</p>", it) } execute(".cos {0}") { assertEquals("<p>1</p>", it) } execute(".sin {0}") { assertEquals("<p>0</p>", it) } execute(".tan {0}") { assertEquals("<p>0</p>", it) } execute(".cos {.pi}") { assertEquals("<p>-1</p>", it) } execute(".pi::multiply {2}::cos") { assertEquals("<p>1</p>", it) } execute( """ .var {radius} {8} If we try to calculate the **surface** of a circle of **radius .radius**, we will find out it is **.multiply {.pow {.radius} to:{2}} by:{.pi}** """.trimIndent(), ) { assertEquals( "<p>If we try to calculate the <strong>surface</strong> of a circle of <strong>radius 8</strong>,\n" + "we will find out it is <strong>201.06194</strong></p>", it, ) } execute( """ .var {radius} {8} If we try to calculate the **surface** of a circle of **radius .radius**, we will find out it is **.pow {.radius} to:{2}::multiply by:{.pi}** """.trimIndent(), ) { assertEquals( "<p>If we try to calculate the <strong>surface</strong> of a circle of <strong>radius 8</strong>,\n" + "we will find out it is <strong>201.06194</strong></p>", it, ) } execute(".pow {8} to:{2}::multiply by:{.pi}::round") { assertEquals("<p>201</p>", it) } execute(".pow {8} to:{2}::multiply by:{.pi}::truncate decimals:{2}") { assertEquals("<p>201.06</p>", it) } execute(".pow {8} to:{2}::multiply by:{.pi}::truncate decimals:{1}") { assertEquals("<p>201</p>", it) } execute(".pow {8} to:{2}::multiply by:{.pi}::truncate decimals:{0}") { assertEquals("<p>201</p>", it) } assertFailsWith<FunctionCallRuntimeException> { execute(".pow {8} to:{2}::multiply by:{.pi}::truncate decimals:{-1}") {} } assertFailsWith<MismatchingArgumentTypeException> { execute(".pow {8} to:{2}::multiply by:{.pi}::truncate decimals:{1.5}") {} } } @Test fun fibonacci() { // Iterative Fibonacci sequence calculation. val iterative = """ .var {t1} {0} .var {t2} {1} .table .foreach {0..4} | $ F_\{.1} $ | |:-------------:| | .t1 | .var {tmp} {.sum {.t1} {.t2}} .t1 {.t2} .t2 {.tmp} """.trimIndent() val iterativeInFunction = """ .function {fib} n: .var {t1} {0} .var {t2} {1} .table .repeat {.n} | $ F_\{.subtract {.1} {1}} $ | |:--------------------------:| | .t1 | .var {tmp} {.sum {.t1} {.t2}} .t1 {.t2} .var {t2} {.tmp} .fib {5} """.trimIndent() val alternativeIterative = """ .var {t1} {0} .var {t2} {1} .function {newtablecolumn} n: | $ F_\{.n} $ | |:-------------:| | .t1 | .table .foreach {0..4} .newtablecolumn {.1} .var {tmp} {.sum {.t1} {.t2}} .var {t1} {.t2} .var {t2} {.tmp} """.trimIndent() // Recursive Fibonacci sequence calculation. val recursive = """ .function {fib} n: .if { .islower {.n} than:{2} } .n .ifnot { .islower {.n} than:{2} } .sum { .fib { .subtract {.n} {1} } } { .fib { .subtract {.n} {2} } } .table .foreach {0..4} | $ F_\{.1} $ | |:------------:| | .fib {.1} | """.trimIndent() val out = "<table><thead><tr>" + "<th align=\"center\"><formula>F_{0}</formula></th>" + "<th align=\"center\"><formula>F_{1}</formula></th>" + "<th align=\"center\"><formula>F_{2}</formula></th>" + "<th align=\"center\"><formula>F_{3}</formula></th>" + "<th align=\"center\"><formula>F_{4}</formula></th>" + "</tr></thead><tbody><tr>" + "<td align=\"center\">0</td>" + "<td align=\"center\">1</td>" + "<td align=\"center\">1</td>" + "<td align=\"center\">2</td>" + "<td align=\"center\">3</td>" + "</tr></tbody></table>" execute(iterative) { assertEquals(out, it) } execute(iterativeInFunction) { assertEquals(out, it) } execute(alternativeIterative) { assertEquals(out, it) } execute(recursive) { assertEquals(out, it) } } @Test fun `layout builder`() { val layoutFunction = """ .noautopagebreak .function {mylayout} name number: # Hello, .name! .number $ \times $ .number is .multiply {.number} by:{.number} ### End """.trimIndent() execute( "$layoutFunction\n.mylayout {world} {3}", ) { assertEquals( "<h1>Hello, world!</h1><p>3 <formula>\\times</formula> 3 is 9</p><h3>End</h3>", it, ) } execute( layoutFunction + """ .repeat {4} n: .mylayout {world} {.n} """.trimIndent(), ) { assertEquals( "<h1>Hello, world!</h1><p>1 <formula>\\times</formula> 1 is 1</p><h3>End</h3>" + "<h1>Hello, world!</h1><p>2 <formula>\\times</formula> 2 is 4</p><h3>End</h3>" + "<h1>Hello, world!</h1><p>3 <formula>\\times</formula> 3 is 9</p><h3>End</h3>" + "<h1>Hello, world!</h1><p>4 <formula>\\times</formula> 4 is 16</p><h3>End</h3>", it, ) } execute( """ .function {poweredby} credits: .text {powered by .credits} size:{small} variant:{smallcaps} This **exciting feature**, .poweredby {[Quarkdown](https://github.com/iamgio/quarkdown)}, looks great! """.trimIndent(), ) { assertEquals( "<p>This <strong>exciting feature</strong>, <span class=\"size-small\" style=\"font-variant: small-caps;\">" + "powered by <a href=\"https://github.com/iamgio/quarkdown\">Quarkdown</a></span>, looks great!</p>", it, ) } execute( """ .repeat {3} .container width:{1cm} Item .1 """.trimIndent(), ) { assertEquals( "<div class=\"container\" style=\"width: 1.0cm;\">" + "<p>Item 1</p></div><div class=\"container\" style=\"width: 1.0cm;\">" + "<p>Item 2</p></div><div class=\"container\" style=\"width: 1.0cm;\">" + "<p>Item 3</p></div>", it, ) } execute( """ .docauthors - Llion Jones - branch: Google Research - email: llion@google.com - Aidan N. Gomez - branch: University of Toronto - email: aidan@cs.toronto.edu - Łukasz Kaiser - branch: Google Brain - email: lukaszkaiser@google.com - Illia Polosukhin - email: illia.polosukhin@gmail.com .function {author} name branch email: .container **.name** .branch .text {.email} size:{small} .whitespace .grid columns:{2} alignment:{spacearound} .foreach {.docauthors} name info: .author {.name} {.get {branch} from:{.info} orelse:{-}} {.get {email} from:{.info}} """.trimIndent(), ) { assertEquals( "<div style=\"grid-template-columns: auto auto; justify-content: space-around; align-items: center;\" " + "class=\"stack stack-grid\">" + "<div class=\"container\">" + "<p><strong>Llion Jones</strong><br />Google Research<br />" + "<span class=\"size-small\"><a href=\"llion@google.com\">llion@google.com</a></span><br /><span>&nbsp;</span></p>" + "</div><div class=\"container\">" + "<p><strong>Aidan N. Gomez</strong>" + "<br />University of Toronto<br />" + "<span class=\"size-small\"><a href=\"aidan@cs.toronto.edu\">aidan@cs.toronto.edu</a></span><br />" + "<span>&nbsp;</span></p>" + "</div><div class=\"container\"><p><strong>Łukasz Kaiser</strong>" + "<br />Google Brain<br />" + "<span class=\"size-small\"><a href=\"lukaszkaiser@google.com\">lukaszkaiser@google.com</a></span><br />" + "<span>&nbsp;</span></p></div>" + "<div class=\"container\"><p><strong>Illia Polosukhin</strong>" + "<br />-<br />" + "<span class=\"size-small\"><a href=\"illia.polosukhin@gmail.com\">illia.polosukhin@gmail.com</a></span><br />" + "<span>&nbsp;</span></p></div></div>", it, ) } } @Test fun functional() { assertFails { // Lambda cannot be inferred: .1 is not defined execute( """ .takeif {3} { .islower {.1} than:{5} } """.trimIndent(), ) {} } execute( """ .takeif {3} { @lambda .islower {.1} than:{5} } """.trimIndent(), ) { assertEquals("<p>3</p>", it) } execute( """ .takeif {3} { @lambda .islower {.1} than:{2} } """.trimIndent(), ) { assertEquals("<p><span class=\"codespan-content\"><code>None</code></span></p>", it) } execute( """ .takeif {3} { @lambda x: .islower {.x} than:{5} } """.trimIndent(), ) { assertEquals("<p>3</p>", it) } execute( """ .otherwise {.takeif {3} {@lambda x: .iseven {.x}}} {0} """.trimIndent(), ) { assertEquals("<p>0</p>", it) } // With chaining. execute( """ .takeif {3} {@lambda x: .iseven {.x}}::otherwise {0} """.trimIndent(), ) { assertEquals("<p>0</p>", it) } } @Test fun `chart of element repetition`() { execute( """ .var {x} - b - a - b - c - b - a - d - e - f - e - d - b .x {.x::sorted} .xychart bars:{yes} lines:{no} xtags:{.x::distinct} .foreach {.x::groupvalues} .1::size """.trimIndent(), ) { assertEquals( """ <figure><pre class="mermaid fill-height">xychart-beta x-axis [a, b, c, d, e, f] bar [2.0, 4.0, 1.0, 2.0, 2.0, 1.0] </pre></figure> """.trimIndent(), it, ) } } @Test fun `chart from csv`() { execute( """ .let {.csv {csv/sales.csv}} data: .var {columns} .tablecolumns .data .xychart xtags:{.columns::first} y:{Sales} bars:{yes} .columns::second .columns::third """.trimIndent(), ) { assertEquals( """ <figure><pre class="mermaid fill-height">xychart-beta x-axis [2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025] y-axis &quot;Sales&quot; bar [230.0, 190.0, 180.0, 175.0, 200.0, 250.0, 290.0, 350.0, 470.0] line [230.0, 190.0, 180.0, 175.0, 200.0, 250.0, 290.0, 350.0, 470.0] bar [85.0, 100.0, 135.0, 180.0, 240.0, 320.0, 430.0, 580.0, 800.0, 0.0] line [85.0, 100.0, 135.0, 180.0, 240.0, 320.0, 430.0, 580.0, 800.0, 0.0] </pre></figure> """.trimIndent(), it.toString().replace("\t", " "), ) } } @Test fun `all emojis to table`() { execute( """ .var {headers} - Emoji - Code .tablebyrows {.headers} .foreach {.allemojis} emoji code: .pair {.emoji} {.code::codespan} """.trimIndent(), ) { val out = "<table><thead><tr><th>Emoji</th><th>Code</th></tr></thead><tbody>" + "<tr><td>\uD83D\uDE00</td><td><span class=\"codespan-content\"><code>smile</code></span></td></tr>" + "<tr><td>\uD83D\uDE03</td><td><span class=\"codespan-content\"><code>smile-with-big-eyes</code></span></td></tr>" + "<tr><td>\uD83D\uDE04</td><td><span class=\"codespan-content\"><code>grin</code></span></td></tr><tr>" assertEquals( out, it.toString().substring(0, out.length), ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/SecurityTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.pipeline.error.PipelineException import com.quarkdown.core.pipeline.output.visitor.FileResourceExporter import com.quarkdown.test.util.execute import java.io.File import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith /** * Tests for possible security issues. */ class SecurityTest { @Test fun `authors injection`() { execute( """ .docauthors - "><script>alert('XSS')</script> """.trimIndent(), afterPostRenderingHook = { assertContains(it, "&lt;script&gt;") assertContains(it, "&lt;/script&gt;") assertContains(it, "name=\"author\"") }, ) {} } @Test fun `mermaid injection`() { execute( """ .mermaid graph TD A --> B</pre>Hello """.trimIndent(), ) { assertEquals( "<figure><pre class=\"mermaid fill-height\">" + "graph TD\n A --&gt; B&lt;/pre&gt;Hello</pre></figure>", it, ) } } @Test fun `file tree injection`() { execute( """ .filetree - </div><script>alert('XSS')</script> """.trimIndent(), ) { val escaped = "&lt;/div&gt;&lt;script&gt;alert(&rsquo;XSS&rsquo;)&lt;/script&gt;" assertContains(it, ">$escaped</li>") assertContains(it, "data-name=\"$escaped\"") } } @Test fun `infinite recursion in strict mode`() { assertFailsWith<PipelineException> { execute( """ .function {myfunc} .myfunc .myfunc """.trimIndent(), ) {} } } @Test fun `docname sanitization at file export time`() { execute( ".docname {../test.abc}", outputResourceHook = { group -> val file = group?.accept(FileResourceExporter(location = File("."), write = false)) assertEquals("../test.abc", group?.name) assertEquals("-.-test.abc", file?.name) }, ) { assertEquals("../test.abc", documentInfo.name) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/SidebarTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import com.quarkdown.test.util.getSubdocumentResource import kotlin.test.Test import kotlin.test.assertContains /** * Tests for navigation sidebar generation via [com.quarkdown.rendering.html.node.SidebarRenderer]. */ class SidebarTest { @Test fun generation() { execute( """ # Title 1 ## Subtitle 1.1 """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), outputResourceHook = { group -> val indexResource = getSubdocumentResource(group, Subdocument.Root, this) val expectedSidebar = "<ol>" + "<li data-target-id=\"title-1\" data-depth=\"1\"><a href=\"#title-1\">Title 1</a>" + "<ol>" + "<li data-target-id=\"subtitle-11\" data-depth=\"2\"><a href=\"#subtitle-11\">Subtitle 1.1</a></li>" + "</ol>" + "</li>" + "</ol>" assertContains(indexResource.content, expectedSidebar) assertContains( indexResource.content, Regex("<template id=\"sidebar-template\">\\s*<nav class=\"sidebar\" role=\"doc-toc\">\\s*<ol>"), ) }, ) {} } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/StrictErrorHandlingTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.function.error.FunctionCallRuntimeException import com.quarkdown.core.function.error.InvalidArgumentCountException import com.quarkdown.core.function.error.InvalidFunctionCallException import com.quarkdown.core.function.error.ParameterAlreadyBoundException import com.quarkdown.core.function.error.UnresolvedReferenceException import com.quarkdown.test.util.execute import org.junit.jupiter.api.Test import kotlin.test.assertFailsWith import kotlin.test.assertIs /** * Tests for strict error handling, where errors throw exceptions. */ class StrictErrorHandlingTest { @Test fun `error on unresolved reference`() { assertFailsWith<UnresolvedReferenceException> { execute(".nonexistent") {} } } @Test fun `error on argument count`() { assertFailsWith<InvalidArgumentCountException> { execute(".sum {2}") {} } assertFailsWith<InvalidArgumentCountException> { execute(".sum {2} {5} {9}") {} } } @Test fun `error on positional parameter already bound`() { assertFailsWith<ParameterAlreadyBoundException> { execute(".sum {2} a:{3}") {} } } @Test fun `error on named parameter already bound`() { assertFailsWith<ParameterAlreadyBoundException> { execute(".sum a:{2} a:{3}") {} } } @Test fun `error on argument type`() { assertFailsWith<InvalidFunctionCallException> { execute(".sum {a} {3}") {} } assertFailsWith<InvalidFunctionCallException> { execute(".sum {2} {.multiply {3} {a}}") {} } assertFailsWith<InvalidFunctionCallException> { execute(".if {hello}\n\t.sum {2} {3} {1}") {} } assertFailsWith<InvalidFunctionCallException> { execute(".row alignment:{center} cross:{hello}\n\tHi") {} } } @Test fun `error on document type`() { assertFailsWith<InvalidFunctionCallException> { execute(".doctype {plain}\n.slides") {} } } @Test fun `runtime error`() { assertFailsWith<FunctionCallRuntimeException> { execute(".csv {nonexistent}") {} }.also { exception -> assertIs<IllegalArgumentException>(exception.cause) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/StringTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for string manipulation. */ class StringTest { @Test fun `explicit string`() { execute(".string {}") { assertEquals("<p></p>", it) } execute(".string {hello}") { assertEquals("<p>hello</p>", it) } execute(".string { hello }") { assertEquals("<p>hello</p>", it) } execute(".string {\" hello \"}") { assertEquals("<p> hello </p>", it) } } @Test fun case() { execute(".uppercase {hello}") { assertEquals("<p>HELLO</p>", it) } execute(".lowercase {HELLO}") { assertEquals("<p>hello</p>", it) } execute(".capitalize {hello}") { assertEquals("<p>Hello</p>", it) } } @Test fun concatenation() { execute(".concatenate {hello} {world}") { assertEquals("<p>helloworld</p>", it) } execute(".concatenate {hello} with:{ world}") { assertEquals("<p>helloworld</p>", it) } execute(".concatenate {hello} {.string {\" world\"}}") { assertEquals("<p>hello world</p>", it) } execute(".concatenate {hello} {world} if:{true}") { assertEquals("<p>helloworld</p>", it) } execute(".concatenate {hello} {world} if:{no}") { assertEquals("<p>hello</p>", it) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/SubdocumentTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.attributes.presence.hasMermaidDiagram import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.document.DocumentType import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.document.sub.SubdocumentOutputNaming import com.quarkdown.test.util.DATA_FOLDER import com.quarkdown.test.util.execute import com.quarkdown.test.util.getSubResources import com.quarkdown.test.util.getSubdocumentResource import com.quarkdown.test.util.getSubdocumentResourceCount import java.io.File import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue private const val NON_EXISTENT_FUNCTION = "somenonexistentfunction" /** * Tests for subdocument generation. */ class SubdocumentTest { private fun subdoc( name: String, content: String, ) = Subdocument.Resource( name = name, path = name, content = content, ) private val simpleSubdoc = subdoc("subdoc1", content = "Content") private val referenceToParentSubdoc = subdoc("subdoc2", content = ".$NON_EXISTENT_FUNCTION") private val definitionSubdoc = subdoc("subdoc3", content = ".function {$NON_EXISTENT_FUNCTION}\n\thello") private val thirdPartySubdoc = subdoc("subdoc4", content = ".mermaid\n\tgraph TD\n\t\tA-->B") private val echoDocumentNameSubdoc = subdoc("subdoc5", content = ".docname") private val modifyAndEchoDocumentNameSubdoc = subdoc("subdoc6", content = ".docname {Changed name}\n\n.docname") @Test fun `root to subdocument`() { execute( "", subdocumentGraph = { it.addVertex(simpleSubdoc).addEdge(Subdocument.Root, simpleSubdoc) }, outputResourceHook = { group -> val resource = getSubdocumentResource(group, simpleSubdoc, this) assertContains(resource.content, "<html>") assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(group)) assertContains(getSubResources(group).map { it.name }, simpleSubdoc.name) }, ) {} } @Test fun `collision-proof subdocument name`() { execute( "", subdocumentGraph = { it.addVertex(simpleSubdoc).addEdge(Subdocument.Root, simpleSubdoc) }, subdocumentNaming = SubdocumentOutputNaming.COLLISION_PROOF, outputResourceHook = { group -> val resources = getSubResources(group).map { it.name } assertContains(resources, simpleSubdoc.uniqueName) assertFalse(simpleSubdoc.name in resources) }, ) {} } @Test fun `document-name subdocument naming with docname set`() { execute( "", subdocumentGraph = { it.addVertex(modifyAndEchoDocumentNameSubdoc).addEdge(Subdocument.Root, modifyAndEchoDocumentNameSubdoc) }, subdocumentNaming = SubdocumentOutputNaming.DOCUMENT_NAME, outputResourceHook = { group -> val resources = getSubResources(group).map { it.name } assertContains(resources, "Changed name") assertFalse(modifyAndEchoDocumentNameSubdoc.name in resources) }, ) {} } @Test fun `document-name subdocument naming falls back to file name`() { execute( "", subdocumentGraph = { it.addVertex(simpleSubdoc).addEdge(Subdocument.Root, simpleSubdoc) }, subdocumentNaming = SubdocumentOutputNaming.DOCUMENT_NAME, outputResourceHook = { group -> val resources = getSubResources(group).map { it.name } assertContains(resources, simpleSubdoc.name) }, ) {} } @Test fun `context should be shared to subdocument`() { execute( """ .doctype {paged} .function {$NON_EXISTENT_FUNCTION} hello """.trimIndent(), subdocumentGraph = { it.addVertex(referenceToParentSubdoc).addEdge(Subdocument.Root, referenceToParentSubdoc) }, outputResourceHook = { group -> val resource = getSubdocumentResource(group, referenceToParentSubdoc, this) assertEquals(DocumentType.PAGED, documentInfo.type) assertContains(resource.content, "paged") }, ) {} } @Test fun `context should not be shared from subdocument to parent`() { execute( ".doctype {paged}", subdocumentGraph = { it.addVertex(definitionSubdoc).addEdge(Subdocument.Root, definitionSubdoc) }, outputResourceHook = { assertEquals(DocumentType.PAGED, documentInfo.type) assertNull(getFunctionByName(NON_EXISTENT_FUNCTION)) }, ) {} } @Test fun `third-party presence should not be shared from subdocument to parent`() { execute( "", subdocumentGraph = { it.addVertex(thirdPartySubdoc).addEdge(Subdocument.Root, thirdPartySubdoc) }, outputResourceHook = { assertFalse(attributes.hasMermaidDiagram) // Root should not have the mermaid script, the subdocument should. val rootResource = getSubdocumentResource(it, Subdocument.Root, this) val subdocResource = getSubdocumentResource(it, thirdPartySubdoc, this) assertFalse(rootResource.content.contains("mermaid.min.js")) assertContains(subdocResource.content, "mermaid.min.js") }, ) {} } @Test fun `subdocument inherits parent's document info`() { execute( ".docname {My doc}", subdocumentGraph = { it.addVertex(echoDocumentNameSubdoc).addEdge(Subdocument.Root, echoDocumentNameSubdoc) }, outputResourceHook = { group -> val subdocResource = getSubdocumentResource(group, echoDocumentNameSubdoc, this) assertEquals("My doc", group?.name) assertEquals("My doc", documentInfo.name) assertContains(subdocResource.content, "<title>My doc</title>") }, ) {} } @Test fun `subdocument should not share document info modifications with parent`() { execute( ".docname {Parent doc}", subdocumentGraph = { it.addVertex(modifyAndEchoDocumentNameSubdoc).addEdge(Subdocument.Root, modifyAndEchoDocumentNameSubdoc) }, outputResourceHook = { group -> val mainResource = getSubdocumentResource(group, Subdocument.Root, this) val subdocResource = getSubdocumentResource(group, modifyAndEchoDocumentNameSubdoc, this) assertEquals("Parent doc", group?.name) assertEquals("Parent doc", documentInfo.name) assertContains(mainResource.content, "<title>Parent doc</title>") assertContains(subdocResource.content, "<title>Changed name</title>") }, ) {} } @Test fun `simple subdocument from file`() { arrayOf( "The link is: [1](subdoc/simple-1.qd)", "The link is: .subdocument {subdoc/simple-1.qd} label:{1}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) { if (subdocument == Subdocument.Root) { assertEquals("<p>The link is: <a href=\"./simple-1\">1</a></p>", it) } } } } @Test fun `simple subdocument from file with anchor`() { arrayOf( "The link is: [1](subdoc/headings-1.qd#a)", "The link is: .subdocument {subdoc/headings-1.qd} label:{1} anchor:{a}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) { if (subdocument == Subdocument.Root) { assertEquals("<p>The link is: <a href=\"./headings-1#a\">1</a></p>", it) } } } } @Test fun `empty label subdocument from file`() { arrayOf( "The link is: [](subdoc/simple-1.qd)", "The link is: .subdocument {subdoc/simple-1.qd}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) { if (subdocument == Subdocument.Root) { assertEquals("<p>The link is: <a href=\"./simple-1\"></a></p>", it) } } } } @Test fun `stdlib call in subdocument from file`() { arrayOf( "[Lorem](subdoc/stdlib-call.qd)", ".subdocument {subdoc/stdlib-call.qd} label:{Lorem}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) { if (subdocument != Subdocument.Root) { assertContains(it, "Lorem ipsum") } } } } @Test fun `stdlib call in included file from subdocument`() { arrayOf( "[Include](subdoc/include-stdlib.qd)", ".subdocument {subdoc/include-stdlib.qd} label:{Include}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) { if (subdocument != Subdocument.Root) { assertContains(it, "Lorem ipsum") } } } } @Test fun `root to gateway to 1 and 2`() { arrayOf( "[Gateway](subdoc/gateway.qd)", ".subdocument {subdoc/gateway.qd} label:{Gateway}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(4, subdocumentGraph.vertices.size) assertEquals(4, getSubdocumentResourceCount(it)) }, ) {} } } @Test fun `circular, root to 1 to 2 to 1`() { arrayOf( "[1](subdoc/circular-1.qd)", ".subdocument {subdoc/circular-1.qd} label:{1}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(3, subdocumentGraph.vertices.size) assertEquals(3, getSubdocumentResourceCount(it)) }, ) {} } } @Test fun `recursive, root to 1 recursively`() { arrayOf( "[1](subdoc/recursive.qd)", ".subdocument {subdoc/recursive.qd} label:{1}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) assertEquals(2, getSubdocumentResourceCount(it)) }, ) {} } } @Test fun `subdocument link should mark current subdocument and account for non-root path`() { arrayOf( "[Document](subdoc/nav-includer.qd)", ".subdocument {subdoc/nav-includer.qd} label:{Document}", ).forEach { source -> execute( source, outputResourceHook = { assertEquals(4, subdocumentGraph.vertices.size) assertEquals(4, getSubdocumentResourceCount(it)) }, ) { if (subdocument.name == "nav-includer") { assertEquals( "<ul><li><a href=\"../simple-1\">1</a></li>" + "<li><a href=\"../simple-2\">2</a></li>" + "<li><a href=\"../nav-includer\" aria-current=\"page\">3</a></li></ul>", it, ) } } } } @Test fun `subdocument link from included file should account for different path`() { execute( ".include {include/subdocument-linker.qd}", outputResourceHook = { assertEquals(2, subdocumentGraph.vertices.size) }, ) {} } @Test fun `subdocument should not update relative paths`() { execute( "[1](include/relative-image.md)", ) { if (subdocument != Subdocument.Root) { assertEquals( "<p>img: <img src=\"../img/icon.png\" alt=\"img\" /></p>", it, ) } } } @Test fun `path-to-root should return correct relative path in subdocuments`() { execute( """ .include {utils/path-to-root.qd} [1](subdoc/subdoc.qd) """.trimIndent(), workingDirectory = File(DATA_FOLDER, "subdoc").resolve("path-to-root"), ) { if (subdocument == Subdocument.Root) { assertEquals( "<p>..</p><p>..</p><p><a href=\"./subdoc\">1</a></p>", it, ) } else { assertEquals( "<p>..</p><p>..${File.separator}subdoc</p>", it, ) } } } @Test fun `including content library in subdocument should not affect parent`() { execute( "[1](subdoc/include-lib-1.qd)", loadableLibraries = setOf("content"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<p><a href=\"./include-lib-1\">1</a></p>", it) } else { assertEquals("<h2>Title</h2><p>Content</p>", it) } } } @Test fun `including symbol library in subdocument should not affect parent`() { execute( "[1](subdoc/include-lib-2.qd)", loadableLibraries = setOf("hello"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<p><a href=\"./include-lib-2\">1</a></p>", it) assertNull(getFunctionByName("hellofromlib")) } else { assertEquals("", it) assertNotNull(getFunctionByName("hellofromlib")) } } } @Test fun `including content library in parent should not affect subdocuments`() { execute( ".include {content}\n\n[1](subdoc/simple-1.qd)", loadableLibraries = setOf("content"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<h2>Title</h2><p>Content</p><p><a href=\"./simple-1\">1</a></p>", it) } else { assertEquals("<p>Hello 1</p>", it) } } } @Test fun `including symbol library in parent should load into subdocuments`() { execute( ".include {hello}\n\n.hellofromlib {X}\n\n[1](subdoc/simple-1.qd)", loadableLibraries = setOf("hello"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<p>Hello, <em>X</em>!</p><p><a href=\"./simple-1\">1</a></p>", it) } else { assertEquals("<p>Hello 1</p>", it) } assertNotNull(getFunctionByName("hellofromlib")) } } @Test fun `including symbol library in subdocument should not affect sibling subdocuments`() { execute( "[1](subdoc/include-lib-2.qd)\n\n[2](subdoc/simple-1.qd)", loadableLibraries = setOf("hello"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<p><a href=\"./include-lib-2\">1</a></p><p><a href=\"./simple-1\">2</a></p>", it) assertNull(getFunctionByName("hellofromlib")) } else if (subdocument.name == "include-lib-2") { assertEquals("", it) assertNotNull(getFunctionByName("hellofromlib")) } else if (subdocument.name == "simple-1") { assertEquals("<p>Hello 1</p>", it) assertNull(getFunctionByName("hellofromlib")) } } } @Test fun `content library can be included in both parent and subdocument without conflict`() { execute( ".include {content}\n\n[1](subdoc/include-lib-1.qd)", loadableLibraries = setOf("content"), useDummyLibraryDirectory = true, ) { if (subdocument == Subdocument.Root) { assertEquals("<h2>Title</h2><p>Content</p><p><a href=\"./include-lib-1\">1</a></p>", it) } else { assertEquals("<h2>Title</h2><p>Content</p>", it) } } } @Test fun `all from directory`() { execute( """ .foreach {.listfiles {subdoc} directories:{no} sortby:{name}} path: .path::subdocument label:{.path::filename extension:{no}} """.trimIndent(), loadableLibraries = setOf("hello", "content"), useDummyLibraryDirectory = true, outputResourceHook = { val files = File(fileSystem.workingDirectory, "subdoc").listFiles()!!.filter { it.isFile } assertEquals(files.size + 1, subdocumentGraph.vertices.size) // +1 for root files.forEach { file -> val subdocName = file.nameWithoutExtension assertTrue(subdocumentGraph.vertices.any { vertex -> vertex.name.startsWith(subdocName) }) } }, ) {} } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/TableComputationTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.util.indent import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for table computation and operations. */ class TableComputationTest { private val table = """ | Name | Age | City | |---------|-----|------| | John | 25 | NY | | Barbara | 102 | SF | | Lisa | 32 | LA | | Mike | 19 | CHI | """.trimIndent().indent("\t") private val john = "<tr><td>John</td><td>25</td><td>NY</td></tr>" private val barbara = "<tr><td>Barbara</td><td>102</td><td>SF</td></tr>" private val lisa = "<tr><td>Lisa</td><td>32</td><td>LA</td></tr>" private val mike = "<tr><td>Mike</td><td>19</td><td>CHI</td></tr>" private fun htmlTable( htmlContent: String, caption: String? = null, ) = "<table><thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>" + "<tbody>$htmlContent</tbody>" + (caption?.let { "<caption class=\"caption-bottom\">$it</caption>" } ?: "") + "</table>" @Test fun `plain sorting, ascending`() { execute(".tablesort column:{2}\n$table") { assertEquals( htmlTable(mike + john + lisa + barbara), it, ) } } @Test fun `plain sorting, descending`() { execute(".tablesort column:{2} order:{descending}\n$table") { assertEquals( htmlTable(barbara + lisa + john + mike), it, ) } } @Test fun `plain filtering`() { execute(".tablefilter {2} {@lambda x: .x::isgreater {20}}\n$table") { assertEquals( htmlTable(john + barbara + lisa), it, ) } } @Test fun `plain sum computing`() { execute(".tablecompute {2} {@lambda x: .x::sumall}\n$table") { assertEquals( htmlTable( john + barbara + lisa + mike + "<tr><td></td><td>178</td><td></td></tr>", ), it, ) } } @Test fun `plain average computing`() { execute(".tablecompute {2} {@lambda .1::average::round}\n$table") { assertEquals( htmlTable( john + barbara + lisa + mike + "<tr><td></td><td>44</td><td></td></tr>", ), it, ) } } @Test fun composition() { execute( ".tablecompute {2} {@lambda x: .x::average::round}\n" + "\t.tablesort {2}\n" + table.indent("\t"), ) { assertEquals( htmlTable( mike + john + lisa + barbara + "<tr><td></td><td>44</td><td></td></tr>", ), it, ) } } @Test fun csv() { arrayOf( ".csv {csv/people.csv}", ".csv {csv/people.csv} mode:{plain}", ".csv {csv/people.csv} mode:{markdown}", ).forEach { source -> execute(source) { assertEquals( htmlTable(john + lisa + mike), it, ) } } } @Test fun `csv with caption`() { execute(".csv {csv/people.csv} caption:{People}") { assertEquals( htmlTable( john + lisa + mike, caption = "People", ), it, ) } } @Test fun `csv with markup and function calls`() { execute(".csv {csv/sums.csv} mode:{markdown}") { assertEquals( "<table><thead>" + "<tr><th></th><th>1</th><th>2</th><th>3</th></tr></thead><tbody>" + "<tr><td><strong>1</strong></td><td>2</td><td>3</td><td>4</td></tr>" + "<tr><td><strong>2</strong></td><td>3</td><td>4</td><td>5</td></tr>" + "<tr><td><strong>3</strong></td><td>4</td><td>5</td><td>6</td></tr>" + "</tbody></table>", it, ) } } @Test fun `compute on csv`() { execute(".tablecompute {2} {@lambda x: .x::average::round}\n\t.csv {csv/people.csv}") { assertEquals( htmlTable( john + lisa + mike + "<tr><td></td><td>25</td><td></td></tr>", ), it, ) } } @Test fun `composition on csv`() { execute( ".tablecompute {2} {@lambda x: .x::average::round}\n" + "\t.tablesort {2}\n" + "\t\t.csv {csv/people.csv}", ) { assertEquals( htmlTable( mike + john + lisa + "<tr><td></td><td>25</td><td></td></tr>", ), it, ) } } @Test fun `get column`() { execute( ".var {col}\n" + "\t.tablecolumn {2}\n" + table.indent("\t\t") + """ .foreach {.col} Cell = .1 """.trimIndent(), ) { assertEquals( "<p>Cell = 25</p>" + "<p>Cell = 102</p>" + "<p>Cell = 32</p>" + "<p>Cell = 19</p>", it, ) } } @Test fun `get columns`() { execute( ".var {cols}\n" + "\t.tablecolumns\n" + table.indent("\t\t") + "\n.cols::size", ) { assertEquals( "<p>3</p>", it, ) } } @Test fun `sum column of csv`() { execute( """ .var {col} .tablecolumn {2} .csv {csv/people.csv} **.col::sumall** """.trimIndent(), ) { assertEquals("<p><strong>76</strong></p>", it) } } @Test fun `generation by rows, no headers`() { execute( """ .tablebyrows - - John - 25 - NY - - Lisa - 32 - LA - - Mike - 19 - CHI """.trimIndent(), ) { assertEquals( htmlTable(john + lisa + mike) .replace( "<thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>", "<thead><tr><th></th><th></th><th></th></tr></thead>", ), it, ) } } @Test fun `generation by rows, with headers`() { execute( """ .var {headers} - Name - Age - City .tablebyrows {.headers} - - John - 25 - NY - - Lisa - 32 - LA - - Mike - 19 - CHI """.trimIndent(), ) { assertEquals( htmlTable(john + lisa + mike), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/TableOfContentsTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.DEFAULT_OPTIONS import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for tables of contents. */ class TableOfContentsTest { @Test fun `table of contents`() { execute( """ .tableofcontents # ABC Hi # DEF Hello """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 class=\"page-break\" id=\"table-of-contents\"></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "<li data-target-id=\"def\" data-depth=\"1\"><a href=\"#def\">DEF</a></li>" + "</ol></nav>" + "<h1 class=\"page-break\" id=\"abc\">ABC</h1><p>Hi</p>" + "<h1 class=\"page-break\" id=\"def\">DEF</h1>" + "<p>Hello</p>", it, ) } execute( """ .tableofcontents title:{_TOC_} # ABC Hi ## _ABC/1_ Hello # DEF DEF/1 --- Hi there ### DEF/2 """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 class=\"page-break\" id=\"table-of-contents\"><em>TOC</em></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a>" + "<ol><li data-target-id=\"abc1\" data-depth=\"2\"><a href=\"#abc1\">ABC/1</a></li></ol></li>" + "<li data-target-id=\"def\" data-depth=\"1\"><a href=\"#def\">DEF</a>" + "<ol><li data-target-id=\"def1\" data-depth=\"2\"><a href=\"#def1\">DEF/1</a>" + "<ol><li data-target-id=\"def2\" data-depth=\"3\"><a href=\"#def2\">DEF/2</a></li>" + "</ol></li></ol></li>" + "</ol></nav>" + "<h1 class=\"page-break\" id=\"abc\">ABC</h1><p>Hi</p>" + "<h2 id=\"abc1\"><em>ABC/1</em></h2><p>Hello</p>" + "<h1 class=\"page-break\" id=\"def\">DEF</h1>" + "<h2 id=\"def1\">DEF/1</h2>" + "<p>Hi there</p>" + "<h3 id=\"def2\">DEF/2</h3>", it, ) } execute( """ # ABC Hi .tableofcontents ##! Ignored from TOC # DEF Hello """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 class=\"page-break\" id=\"abc\">ABC</h1><p>Hi</p>" + "<h1 class=\"page-break\" id=\"table-of-contents\"></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "<li data-target-id=\"def\" data-depth=\"1\"><a href=\"#def\">DEF</a></li>" + "</ol></nav>" + "<h2 id=\"ignored-from-toc\" data-decorative=\"\">Ignored from TOC</h2>" + "<h1 class=\"page-break\" id=\"def\">DEF</h1>" + "<p>Hello</p>", it, ) } } @Test fun `localized table of contents title (docs)`() { execute( """ .doctype {docs} .doclang {english} .noautopagebreak .tableofcontents # ABC """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h3 id=\"table-of-contents\">On this page</h3>" + // Localized name "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "</ol></nav>" + "<h1 id=\"abc\">ABC</h1>", it, ) } } @Test fun `no table of contents title`() { execute(".tableofcontents title:{}") { assertEquals( "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol></ol></nav>", it, ) } } @Test fun `table of contents skipping level 1`() { execute( """ .noautopagebreak .tableofcontents ## ABC ### DEF ## GHI """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 id=\"table-of-contents\"></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"2\"><a href=\"#abc\">ABC</a>" + "<ol><li data-target-id=\"def\" data-depth=\"3\"><a href=\"#def\">DEF</a></li></ol></li>" + "<li data-target-id=\"ghi\" data-depth=\"2\"><a href=\"#ghi\">GHI</a></li>" + "</ol></nav>" + "<h2 id=\"abc\">ABC</h2>" + "<h3 id=\"def\">DEF</h3>" + "<h2 id=\"ghi\">GHI</h2>", it, ) } } @Test fun `table of contents markers`() { execute( """ .tableofcontents title:{***TOC***} maxdepth:{0} .marker {*Marker 1*} # ABC .marker {*Marker 2*} ## DEF """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 class=\"page-break\" id=\"table-of-contents\"><em><strong>TOC</strong></em></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"marker-1\" data-depth=\"0\"><a href=\"#marker-1\">Marker 1</a></li>" + "<li data-target-id=\"marker-2\" data-depth=\"0\"><a href=\"#marker-2\">Marker 2</a></li>" + "</ol></nav>" + "<div class=\"marker\" data-hidden=\"\" id=\"marker-1\"></div>" + "<h1 class=\"page-break\" id=\"abc\">ABC</h1>" + "<div class=\"marker\" data-hidden=\"\" id=\"marker-2\"></div>" + "<h2 id=\"def\">DEF</h2>", it, ) } } @Test fun `unnumbered headings excluded from table of contents`() { execute( """ .noautopagebreak .tableofcontents #! Unnumbered 1 # ABC ##! Unnumbered 2 # DEF ## Y """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 id=\"table-of-contents\"></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "<li data-target-id=\"def\" data-depth=\"1\"><a href=\"#def\">DEF</a>" + "<ol><li data-target-id=\"y\" data-depth=\"2\"><a href=\"#y\">Y</a></li></ol></li>" + "</ol></nav>" + "<h1 id=\"unnumbered-1\" data-decorative=\"\">Unnumbered 1</h1>" + "<h1 id=\"abc\">ABC</h1>" + "<h2 id=\"unnumbered-2\" data-decorative=\"\">Unnumbered 2</h2>" + "<h1 id=\"def\">DEF</h1>" + "<h2 id=\"y\">Y</h2>", it, ) } } @Test fun `exclude unnumbered headings from table of contents, but spread children`() { execute( """ .noautopagebreak .tableofcontents #! ABC ## X ### X/1 ## Y """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 id=\"table-of-contents\"></h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"x\" data-depth=\"2\"><a href=\"#x\">X</a>" + "<ol><li data-target-id=\"x1\" data-depth=\"3\"><a href=\"#x1\">X/1</a></li></ol></li>" + "<li data-target-id=\"y\" data-depth=\"2\"><a href=\"#y\">Y</a></li>" + "</ol></nav>" + "<h1 id=\"abc\" data-decorative=\"\">ABC</h1>" + "<h2 id=\"x\">X</h2>" + "<h3 id=\"x1\">X/1</h3>" + "<h2 id=\"y\">Y</h2>", it, ) } } @Test fun `table of contents focus`() { // Focus execute( """ .tableofcontents title:{TOC} focus:{DEF} # ABC ## X # DEF ## Y """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 class=\"page-break\" id=\"table-of-contents\">TOC</h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a><ol>" + "<li data-target-id=\"x\" data-depth=\"2\"><a href=\"#x\">X</a></li></ol></li>" + "<li data-target-id=\"def\" data-depth=\"1\" class=\"focused\"><a href=\"#def\">DEF</a><ol>" + "<li data-target-id=\"y\" data-depth=\"2\"><a href=\"#y\">Y</a></li></ol></li>" + "</ol></nav>" + "<h1 class=\"page-break\" id=\"abc\">ABC</h1>" + "<h2 id=\"x\">X</h2>" + "<h1 class=\"page-break\" id=\"def\">DEF</h1>" + "<h2 id=\"y\">Y</h2>", it, ) } } @Test fun `table of contents custom heading depth`() { execute( """ .noautopagebreak .tableofcontents headingdepth:{3} # ABC """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h3 id=\"table-of-contents\"></h3>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "</ol></nav>" + "<h1 id=\"abc\">ABC</h1>", it, ) } } @Test fun `table of contents heading indexed in toc, unnumbered`() { execute( """ .noautopagebreak .tableofcontents title:{TOC} indexheading:{yes} # ABC # DEF """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<h1 id=\"table-of-contents\">TOC</h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"table-of-contents\" data-depth=\"1\">" + "<a href=\"#table-of-contents\">TOC</a></li>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "<li data-target-id=\"def\" data-depth=\"1\"><a href=\"#def\">DEF</a></li>" + "</ol></nav>" + "<h1 id=\"abc\">ABC</h1>" + "<h1 id=\"def\">DEF</h1>", it, ) } } @Test fun `table of contents heading indexed in toc, numbered`() { execute( """ .numbering - headings: 1.A.a .noautopagebreak .tableofcontents title:{TOC} indexheading:{yes} numberheading:{yes} # ABC # DEF """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true, enableLocationAwareness = true), ) { assertEquals( "<h1 id=\"table-of-contents\" data-location=\"1\">TOC</h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"table-of-contents\" data-depth=\"1\" data-location=\"1\">" + "<a href=\"#table-of-contents\">TOC</a></li>" + "<li data-target-id=\"abc\" data-depth=\"1\" data-location=\"2\"><a href=\"#abc\">ABC</a></li>" + "<li data-target-id=\"def\" data-depth=\"1\" data-location=\"3\"><a href=\"#def\">DEF</a></li>" + "</ol></nav>" + "<h1 id=\"abc\" data-location=\"2\">ABC</h1>" + "<h1 id=\"def\" data-location=\"3\">DEF</h1>", it, ) } } @Test fun `heading primitive numbered but not indexed`() { execute( """ .noautopagebreak .tableofcontents title:{} .heading {Tracked} depth:{1} numbered:{yes} indexed:{no} # ABC """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "</ol></nav>" + "<h1 id=\"tracked\">Tracked</h1>" + "<h1 id=\"abc\">ABC</h1>", it, ) } } @Test fun `heading primitive not numbered and not indexed`() { execute( """ .noautopagebreak .tableofcontents title:{} .heading {Untracked} depth:{1} numbered:{no} indexed:{no} # ABC """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "</ol></nav>" + "<h1 id=\"untracked\">Untracked</h1>" + "<h1 id=\"abc\">ABC</h1>", it, ) } } @Test fun `heading primitive indexed in toc`() { execute( """ .noautopagebreak .tableofcontents title:{} .heading {Custom} depth:{2} ref:{custom} # ABC """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true), ) { assertEquals( "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"custom\" data-depth=\"2\"><a href=\"#custom\">Custom</a></li>" + "<li data-target-id=\"abc\" data-depth=\"1\"><a href=\"#abc\">ABC</a></li>" + "</ol></nav>" + "<h2 id=\"custom\">Custom</h2>" + "<h1 id=\"abc\">ABC</h1>", it, ) } } @Test fun `table of contents numbering`() { // Numbering execute( """ .numbering - headings: 1.A.a .noautopagebreak .tableofcontents title:{TOC} # A ## A/1 ### A/1/1 ## A/2 # B ### B/0/1 """.trimIndent(), DEFAULT_OPTIONS.copy(enableAutomaticIdentifiers = true, enableLocationAwareness = true), ) { assertEquals( "<h1 id=\"table-of-contents\">TOC</h1>" + "<nav role=\"table-of-contents\" data-role=\"table-of-contents\"><ol>" + "<li data-target-id=\"a\" data-depth=\"1\" data-location=\"1\"><a href=\"#a\">A</a>" + "<ol><li data-target-id=\"a1\" data-depth=\"2\" data-location=\"1.A\"><a href=\"#a1\">A/1</a>" + "<ol><li data-target-id=\"a11\" data-depth=\"3\" data-location=\"1.A.a\"><a href=\"#a11\">A/1/1</a></li></ol></li>" + "<li data-target-id=\"a2\" data-depth=\"2\" data-location=\"1.B\"><a href=\"#a2\">A/2</a></li></ol></li>" + "<li data-target-id=\"b\" data-depth=\"1\" data-location=\"2\"><a href=\"#b\">B</a>" + "<ol><li data-target-id=\"b01\" data-depth=\"3\" data-location=\"2.0.a\"><a href=\"#b01\">B/0/1</a></li></ol></li>" + "</ol></nav>" + "<h1 id=\"a\" data-location=\"1\">A</h1>" + "<h2 id=\"a1\" data-location=\"1.A\">A/1</h2>" + "<h3 id=\"a11\" data-location=\"1.A.a\">A/1/1</h3>" + "<h2 id=\"a2\" data-location=\"1.B\">A/2</h2>" + "<h1 id=\"b\" data-location=\"2\">B</h1>" + "<h3 id=\"b01\" data-location=\"2.0.a\">B/0/1</h3>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/TablesTest.kt ================================================ package com.quarkdown.test import com.quarkdown.core.ast.attributes.presence.hasMath import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Tests for Markdown tables. */ class TablesTest { @Test fun `one-row table`() { execute("| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |") { assertEquals( "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>", it, ) } } @Test fun `one-row table with math`() { execute("| Header 1 | Header 2 |\n|----------|----------|\n| $ X $ | $ Y $ |") { assertEquals( "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td><formula>X</formula></td>" + "<td><formula>Y</formula></td></tr></tbody></table>", it, ) assertTrue(attributes.hasMath) // Ensures the tree traversal visits table cells too. } } @Test fun `one-row table with alignment`() { execute("| Header 1 | Header 2 | Header 3 |\n|:---------|:--------:|---------:|\n| Cell 1 | Cell 2 | Cell 3 |") { assertEquals( "<table><thead><tr><th align=\"left\">Header 1</th><th align=\"center\">Header 2</th>" + "<th align=\"right\">Header 3</th></tr></thead><tbody><tr><td align=\"left\">Cell 1</td>" + "<td align=\"center\">Cell 2</td><td align=\"right\">Cell 3</td></tr></tbody></table>", it, ) } } @Test fun `one-row table with alignment and caption`() { execute( """ | Header 1 | Header 2 | Header 3 | |----------|:--------:|----------| | Cell 1 | Cell 2 | Cell 3 | 'Table caption' """.trimIndent(), ) { assertEquals( "<table><thead><tr><th>Header 1</th><th align=\"center\">Header 2</th>" + "<th>Header 3</th></tr></thead><tbody><tr><td>Cell 1</td>" + "<td align=\"center\">Cell 2</td><td>Cell 3</td></tr></tbody>" + "<caption class=\"caption-bottom\">Table caption</caption></table>", it, ) } } @Test fun `multi-row table`() { execute( """ | Header 1 | Header 2 | |----------|----------| | Cell 1 | Cell 2 | | Cell 3 | Cell 4 | """.trimIndent(), ) { assertEquals( "<table><thead><tr><th>Header 1</th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr>" + "<tr><td>Cell 3</td><td>Cell 4</td></tr></tbody></table>", it, ) } } @Test fun `table with empty header`() { execute( """ | | Header 2 | |----------|----------| | Cell 1 | Cell 2 | | Cell 3 | Cell 4 | """.trimIndent(), ) { assertEquals( "<table><thead><tr><th></th><th>Header 2</th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr>" + "<tr><td>Cell 3</td><td>Cell 4</td></tr></tbody></table>", it, ) } } // #244 @Test fun `table with empty headers`() { execute( """ | | | |----------|----------| | Cell 1 | Cell 2 | | Cell 3 | Cell 4 | """.trimIndent(), ) { assertEquals( "<table><thead><tr><th></th><th></th></tr></thead>" + "<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr>" + "<tr><td>Cell 3</td><td>Cell 4</td></tr></tbody></table>", it, ) } } // #241 @Test fun `cell separator in code spans is not skipped, and can be escaped`() { execute( """ |1 | 2| 3| |--|--|--| | a|a\|b|c| | `a | a` | `b` | | `a \| a` | `b` | """.trimIndent(), ) { assertEquals( "<table><thead>" + "<tr><th>1</th><th>2</th><th>3</th></tr>" + "</thead><tbody>" + "<tr><td>a</td><td>a|b</td><td>c</td></tr>" + "<tr><td>`a</td><td>a`</td><td><span class=\"codespan-content\"><code>b</code></span></td></tr>" + "<tr><td><span class=\"codespan-content\"><code>a | a</code></span></td>" + "<td><span class=\"codespan-content\"><code>b</code></span></td><td></td></tr>" + "</tbody></table>", it, ) } } // #40 @Test fun `unformatted table`() { execute( """ | Topic | Description | |------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| | [link 1](./practical-lion-recognition) | Describes the directory structure and exactly how tigers and lions are often mistaken for jaguars by application developers | | [link2/stuff/and more](./picnic-control-1.xy) | Provides a worked example that shows how picnics can be controlled at a den, pack and organization level | | [link3/production and stuff](./example-stuff.xy) | Provides a worked example that shows | | [link4](internal-Service-Structure.xy) | Describes the internal components of the and how they work | | [Link5 is established](identity.xy) | Describes how user and workloads identities | | Link the sixth | Description of the externally accessible REST and gRPC API | | And the seventh | the source code for the different components are laid out | """.trimIndent(), ) { assertTrue(it.startsWith("<table>")) assertContains(it, "link4") assertTrue(it.endsWith("</table>")) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/TextTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * Tests for text formatting. */ class TextTest { @Test fun `simple text`() { execute("Hello, world!") { assertEquals("<p>Hello, world!</p>", it) } } @Test fun paragraphs() { execute( """ First paragraph. Second paragraph. Third paragraph. """.trimIndent(), ) { assertEquals( "<p>First paragraph.</p><p>Second paragraph.</p><p>Third paragraph.</p>", it, ) } } @Test fun `standard line break`() { execute("This is a line break. \nThis is the next line.") { assertEquals("<p>This is a line break.<br />This is the next line.</p>", it) } } @Test fun `line break function`() { execute("This is a line break. .br This is the next line.") { assertEquals("<p>This is a line break. <br /> This is the next line.</p>", it) } } @Test fun `formatted text`() { execute( """ This is *some* **text**. This _is_ __great__! """.trimIndent(), ) { assertEquals( "<p>This is <em>some</em> <strong>text</strong>. This <em>is</em> <strong>great</strong>!</p>", it, ) } } @Test fun `text replacement`() { execute( """ > This is a **"quote"** with 'text *replacement*'. > This is a feature of Quarkdown--the Turing complete Markdown--by iamgio (C) 2024 - all rights reserved. > => Quarkdown != other Markdown flavors... <- """.trimIndent(), ) { assertEquals( "<blockquote><p>" + "This is a <strong>&ldquo;quote&rdquo;</strong> with &lsquo;text <em>replacement</em>&rsquo;.<br />" + "This is a feature of Quarkdown&mdash;the Turing complete Markdown&mdash;by iamgio &copy; 2024 " + "&ndash; all rights reserved.\n" + "&rArr; Quarkdown &ne; other Markdown flavors&hellip; &larr;" + "</p></blockquote>", it, ) } } @Test fun `text in headings`() { execute(".noautopagebreak\n# Title\n Hello, world!\n## Subtitle\nHello, world!") { assertEquals( "<h1>Title</h1><p>Hello, world!</p><h2>Subtitle</h2><p>Hello, world!</p>", it, ) } } @Test fun links() { execute("Hello, **world**! [_link_](https://example.com \"title\")") { assertEquals( "<p>Hello, <strong>world</strong>! <a href=\"https://example.com\" title=\"title\"><em>link</em></a></p>", it, ) } } @Test fun `advanced text formatting`() { execute("This is a .text {small text} size:{tiny} variant:{smallcaps}") { assertEquals( "<p>This is a <span class=\"size-tiny\" style=\"font-variant: small-caps;\">small text</span></p>", it, ) } } @Test fun `subscript and superscript`() { execute("H{.text {2} script:{sub}}O is water. E = mc{.text {2} script:{sup}}") { assertEquals( "<p>H<sub>2</sub>O is water. E = mc<sup>2</sup></p>", it, ) } } @Test fun `simple whitespace`() { execute( """ Line 1 .whitespace Line 2 after a long break """.trimIndent(), ) { assertEquals("<p>Line 1</p><span>&nbsp;</span><p>Line 2 after a long break</p>", it) } } @Test fun `sized whitespace`() { execute("A .whitespace width:{1cm} B") { assertEquals("<p>A <div style=\"width: 1.0cm;\"></div> B</p>", it) } execute("A .whitespace width:{1cm} height:{3mm} B") { assertEquals("<p>A <div style=\"width: 1.0cm; height: 3.0mm;\"></div> B</p>", it) } } @Test fun `case transformation`() { execute("Hello, World! .uppercase {Hello, World!} .lowercase {Hello, World!} .capitalize {hello, world!}") { assertEquals( "<p>Hello, World! HELLO, WORLD! hello, world! Hello, world!</p>", it, ) } } @Test fun `quote type`() { arrayOf("> Tip: this is a tip", "> [!TIP]\n> this is a tip").forEach { source -> execute(".doclang {English}\n$source") { assertEquals( "<blockquote class=\"tip\" style=\"--quote-type-label: 'Tip';\" data-labeled=\"\"><p>this is a tip</p></blockquote>", it, ) } } } @Test fun `quote type with non-paragraph first child`() { execute( """ .doclang {English} > [!TIP] > - this is > - a tip """.trimIndent(), ) { assertEquals( "<blockquote class=\"tip\" style=\"--quote-type-label: 'Tip';\" data-labeled=\"\">" + "<p data-accept-empty=\"\"></p>" + "<ul><li>this is</li><li>a tip</li></ul>" + "</blockquote>", it, ) } } @Test fun `quote type with empty content`() { execute( """ .doclang {English} > [!TIP] """.trimIndent(), ) { assertEquals( "<blockquote class=\"tip\" style=\"--quote-type-label: 'Tip';\" data-labeled=\"\"><p data-accept-empty=\"\"></p></blockquote>", it, ) } } @Test fun `quote attribution`() { execute( """ .doclang {Italian} > Tip: you could try Quarkdown. > It's a cool language! > - **iamgio** > Important: leave a feedback! """.trimIndent(), ) { assertEquals( "<blockquote class=\"tip\" style=\"--quote-type-label: 'Consiglio';\" data-labeled=\"\">" + "<p>you could try Quarkdown.<br />" + "It&rsquo;s a cool language!</p>" + "<p class=\"attribution\"><strong>iamgio</strong></p>" + "</blockquote>" + "<blockquote class=\"important\" style=\"--quote-type-label: 'Importante';\" data-labeled=\"\">" + "<p>leave a feedback!</p>" + "</blockquote>", it, ) } } @Test fun `text alignment`() { execute( """ A .align {end} ### B C """.trimIndent(), ) { assertEquals( "<p>A</p>" + "<div class=\"container fullwidth\" style=\"justify-items: end; text-align: end;\"><h3>B</h3></div>" + "<p>C</p>", it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/UtilitiesTest.kt ================================================ package com.quarkdown.test import com.quarkdown.test.util.execute import kotlin.test.Test import kotlin.test.assertEquals /** * */ class UtilitiesTest { @Test fun `show quarkdown source and result`() { val input = """ # Hello This is **Quarkdown**... .text {!} size:{huge} .container Nice! """ val codeOutput = "<hr />" + "<pre><code class=\"language-markdown\">\n${input.trimIndent()}</code></pre>" val contentOutput = "<h1 class=\"page-break\">Hello</h1><p>This is <strong>Quarkdown</strong>&hellip; " + "<span class=\"size-huge\">!</span></p><div class=\"container\">" + "<p>Nice!</p>" + "</div>" execute( """ .function {sourceresult} source: --- .code {markdown} .source .source .sourceresult $input """.trimIndent(), ) { assertEquals(codeOutput + contentOutput, it) } execute( """ .function {sourceresult} shrinkvertical? source: --- .code {markdown} .source .var {output} .container alignment:{center} fullwidth:{yes} .source .output .sourceresult $input """.trimIndent(), ) { assertEquals( codeOutput + "<div class=\"container fullwidth\" style=\"justify-items: center; text-align: center;\">$contentOutput</div>", it, ) } execute( """ .function {sourceresult} shrinkvertical? source: --- .code {markdown} .source .var {output} .container = .var {voffset} {-50} .container alignment:{center} margin:{.voffset::takeif {.shrinkvertical::otherwise {no}}::otherwise {0} 0 0 0} fullwidth:{yes} .source .output .sourceresult $input .sourceresult shrinkvertical:{no} $input .sourceresult shrinkvertical:{yes} $input """.trimIndent(), ) { fun output(shrinkVertical: Boolean) = codeOutput + "<div class=\"container\"><p>=</p></div>" + "<div class=\"container fullwidth\" style=\"" + "margin: ${if (shrinkVertical) "-50.0px" else "0.0px"} 0.0px 0.0px 0.0px; justify-items: center; text-align: center;" + "\">" + contentOutput + "</div>" assertEquals( output(false) + output(false) + output(true), it, ) } execute( """ .function {sourceresult} animated? source: --- .code {markdown} .source .var {output} .container = .container .source .let {.animated::otherwise {no}} .if {.1} .container .output .ifnot {.1} .output .sourceresult $input .sourceresult animated:{no} $input .sourceresult animated:{yes} $input """.trimIndent(), ) { fun output(animate: Boolean) = codeOutput + (if (animate) "<div class=\"container\">" else "") + "<div class=\"container\"><p>=</p></div>" + "<div class=\"container\">$contentOutput</div>" + if (animate) "</div>" else "" assertEquals( output(false) + output(false) + output(true), it, ) } } } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/util/Launcher.kt ================================================ package com.quarkdown.test.util import com.quarkdown.core.context.Context import com.quarkdown.core.context.MutableContext import com.quarkdown.core.context.MutableContextOptions import com.quarkdown.core.context.subdocument.subdocumentGraph import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.document.sub.SubdocumentOutputNaming import com.quarkdown.core.flavor.RendererFactory import com.quarkdown.core.flavor.quarkdown.QuarkdownFlavor import com.quarkdown.core.graph.VisitableOnceGraph import com.quarkdown.core.pipeline.Pipeline import com.quarkdown.core.pipeline.PipelineHooks import com.quarkdown.core.pipeline.PipelineOptions import com.quarkdown.core.pipeline.error.PipelineErrorHandler import com.quarkdown.core.pipeline.error.StrictPipelineErrorHandler import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.rendering.RenderingComponents import com.quarkdown.rendering.html.extension.html import com.quarkdown.stdlib.Stdlib import java.io.File // Folder to retrieve test data from. const val DATA_FOLDER = "src/test/resources/data" // Folder to retrieve 'dummy' libraries from, relative to the data folder. private const val LOCAL_LIBRARY_DIRECTORY = "libraries" // Folder to retrieve actual libraries from. private const val GLOBAL_LIBRARY_DIRECTORY = "../quarkdown-libs/src/main/resources" // Default execution options. val DEFAULT_OPTIONS = MutableContextOptions( enableAutomaticIdentifiers = false, enableLocationAwareness = false, ) /** * Executes a Quarkdown source. * @param source Quarkdown source to execute * @param options execution options * @param renderer function that provides the rendering components to use (defaults to HTML) * @param workingDirectory working directory to use for the execution, used for resolving relative paths and as the root for the file system * @param subdocumentGraph modifier of the subdocument graph before rendering * @param loadableLibraries file names to export as libraries from the `data/libraries` folder, and loadable by the user via `.include` * @param useDummyLibraryDirectory whether to use the dummy library directory for loading libraries instead of the one from the `libs` module * @param errorHandler error handler to use * @param enableMediaStorage whether the media storage system should be enabled. * If enabled, nodes that reference media (e.g. images) will instead reference the path to the media on the local storage * @param subdocumentNaming the strategy used to determine subdocument output file names * @param outputResourceHook action run after the pipeline execution, with the output resource as a parameter * @param afterPostRenderingHook action run after post-rendering. Parameters are the pipeline context and the post-rendered result * @param afterRenderingHook action run after rendering. Parameters are the pipeline context and the rendered result */ fun execute( source: String, options: MutableContextOptions = DEFAULT_OPTIONS.copy(), renderer: (RendererFactory, Context) -> RenderingComponents = { rendererFactory, ctx -> rendererFactory.html(ctx) }, workingDirectory: File = File(DATA_FOLDER), subdocumentGraph: (VisitableOnceGraph<Subdocument>) -> VisitableOnceGraph<Subdocument> = { it }, loadableLibraries: Set<String> = emptySet(), useDummyLibraryDirectory: Boolean = false, errorHandler: PipelineErrorHandler = StrictPipelineErrorHandler(), enableMediaStorage: Boolean = false, subdocumentNaming: SubdocumentOutputNaming = SubdocumentOutputNaming.FILE_NAME, outputResourceHook: Context.(OutputResource?) -> Unit = {}, afterPostRenderingHook: Context.(CharSequence) -> Unit = {}, afterRenderingHook: Context.(CharSequence) -> Unit, ) { val context = MutableContext( QuarkdownFlavor, options = options, loadableLibraries = LibraryUtils.export( loadableLibraries, if (useDummyLibraryDirectory) { File(DATA_FOLDER, LOCAL_LIBRARY_DIRECTORY) } else { File(GLOBAL_LIBRARY_DIRECTORY) }, ), ) val hooks = PipelineHooks( afterTreeTraversal = { context.subdocumentGraph = subdocumentGraph(context.subdocumentGraph) }, afterRendering = { afterRenderingHook(readOnlyContext, it) }, afterPostRendering = { afterPostRenderingHook(readOnlyContext, it) }, ) val pipeline = Pipeline( context, PipelineOptions( errorHandler = errorHandler, workingDirectory = workingDirectory, enableMediaStorage = enableMediaStorage, subdocumentNaming = subdocumentNaming, ), libraries = setOf(Stdlib.library), renderer = renderer, hooks, ) val resource = pipeline.execute(source) outputResourceHook(context, resource) } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/util/LibraryUtils.kt ================================================ package com.quarkdown.test.util import com.quarkdown.core.function.library.Library import com.quarkdown.stdlib.external.QdLibraryExporter import java.io.File /** * Library utilities for testing. */ object LibraryUtils { /** * Exports libraries from .qd files. * @param names names of the libraries to export * @param directory directory containing the .qd files * @return exported libraries, loaded from [directory] and matching [names] with a .qd extension */ fun export( names: Set<String>, directory: File, ): Set<Library> = names .map { QdLibraryExporter( it, ) { File(directory, "$it.qd").reader() }.library }.toSet() } ================================================ FILE: quarkdown-test/src/test/kotlin/com/quarkdown/test/util/OutputResourceUtils.kt ================================================ package com.quarkdown.test.util import com.quarkdown.core.context.Context import com.quarkdown.core.document.sub.Subdocument import com.quarkdown.core.document.sub.getOutputFileName import com.quarkdown.core.media.storage.MEDIA_SUBDIRECTORY_NAME import com.quarkdown.core.pipeline.output.OutputResource import com.quarkdown.core.pipeline.output.OutputResourceGroup import com.quarkdown.core.pipeline.output.TextOutputArtifact import kotlin.test.assertIs import kotlin.test.assertNotNull const val INDEX = "index" /** * Given [group] that is an [OutputResourceGroup], returns the set of sub-resources it contains. */ fun getSubResources(group: OutputResource?): Set<OutputResource> { assertIs<OutputResourceGroup>(group) return group.resources } /** * Given [group] that is an [OutputResourceGroup], retrieves the media resources inside the * `media/` subdirectory. */ fun getMediaResources( group: OutputResource?, subdocumentName: String? = null, ): Set<OutputResource> { val subdocumentGroup = if (subdocumentName != null) { getSubdocumentGroup(group, subdocumentName) } else { group } val mediaGroup = getSubResources(subdocumentGroup).find { it.name == MEDIA_SUBDIRECTORY_NAME } assertIs<OutputResourceGroup>(mediaGroup) return mediaGroup.resources } fun getSubdocumentGroup( group: OutputResource?, name: String, ): OutputResourceGroup { val resources = getSubResources(group) val subdocumentGroup = resources.firstOrNull { it.name == name } as? OutputResourceGroup assertNotNull(subdocumentGroup) return subdocumentGroup } fun getSubdocumentGroup( group: OutputResource?, subdocument: Subdocument, context: Context, ): OutputResourceGroup { val subdocumentGroup = if (subdocument == Subdocument.Root) { group as OutputResourceGroup } else { getSubdocumentGroup(group, subdocument.getOutputFileName(context)) } return subdocumentGroup } /** * Retrieves the resource for the given [subdocument] from the [group]. * * - If [subdocument] is the root, retrieves `index.html`. * - Otherwise, retrieves the `index.html` from the corresponding subdocument group. */ fun getSubdocumentResource( group: OutputResource?, subdocument: Subdocument, context: Context, ): TextOutputArtifact { val subdocumentGroup = getSubdocumentGroup(group, subdocument, context) val resource = subdocumentGroup.resources.first { it.name == INDEX } as? TextOutputArtifact assertNotNull(resource) return resource } /** * Counts how many subdocument resources are present in [group]. * A subdocument resource is either an `index.html` at the root of [group], * or an `index.html` inside a subdocument group. */ fun getSubdocumentResourceCount(group: OutputResource?): Int = getSubResources(group) .count { it.name == INDEX || (it is OutputResourceGroup && it.resources.any { res -> res.name == INDEX }) } ================================================ FILE: quarkdown-test/src/test/resources/data/bib/bibliography.bib ================================================ @article{einstein, author = "Albert Einstein", title = "Zur Elektrodynamik bewegter Körper. (German) [On the electrodynamics of moving bodies]", journal = "Annalen der Physik", volume = "322", number = "10", pages = "891--921", year = "1905", DOI = "http://dx.doi.org/10.1002/andp.19053221004" } @book{latexcompanion, author = "Michel Goossens and Frank Mittelbach and Alexander Samarin", title = "The LaTeX Companion", year = "1993", publisher = "Addison-Wesley", address = "Reading, Massachusetts" } @misc{knuthwebsite, author = "Donald Knuth", title = "Knuth: Computers and Typesetting", url = "http://www-cs-faculty.stanford.edu/\~uno/abcde.html" } ================================================ FILE: quarkdown-test/src/test/resources/data/code.txt ================================================ Line 1 Line 2 Line 3 ================================================ FILE: quarkdown-test/src/test/resources/data/css/style.css ================================================ body { background-color: orange; } ================================================ FILE: quarkdown-test/src/test/resources/data/csv/people.csv ================================================ Name, Age, City John, 25, NY Lisa, 32, LA Mike, 19, CHI ================================================ FILE: quarkdown-test/src/test/resources/data/csv/sales.csv ================================================ Year,A Sales,B Sales 2017,85,230 2018,100,190 2019,135,180 2020,180,175 2021,240,200 2022,320,250 2023,430,290 2024,580,350 2025,800,470 ================================================ FILE: quarkdown-test/src/test/resources/data/csv/sums.csv ================================================ , 1, 2, 3 **1** , .sum {1} {1}, .sum {1} {2}, .sum {1} {3} **2** , .sum {2} {1}, .sum {2} {2}, .sum {2} {3} **3** , .sum {3} {1}, .sum {3} {2}, .sum {3} {3} ================================================ FILE: quarkdown-test/src/test/resources/data/include/absolute-image.md ================================================ img: ![img](/img/icon.png) ================================================ FILE: quarkdown-test/src/test/resources/data/include/basic-source.md ================================================ # Title Some *text*. ================================================ FILE: quarkdown-test/src/test/resources/data/include/document-info-modification.md ================================================ .docname {Modified Title} .doclang {it} ================================================ FILE: quarkdown-test/src/test/resources/data/include/dynamic-value.md ================================================ .function {mynumber} .sum {3} {2} .mynumber ================================================ FILE: quarkdown-test/src/test/resources/data/include/function-definition.md ================================================ .function {hello} x: Hello, .x! ================================================ FILE: quarkdown-test/src/test/resources/data/include/function-with-content.md ================================================ .function {hello} x: Hello, .x! ## Included ``` code code ``` ================================================ FILE: quarkdown-test/src/test/resources/data/include/mutable-data.md ================================================ .var {mygreeting} {Hi} .function {saygreeting} .mygreeting ================================================ FILE: quarkdown-test/src/test/resources/data/include/read-relative-path-in-scope.md ================================================ .read {../code.txt} .if {yes} .read {../code.txt} ================================================ FILE: quarkdown-test/src/test/resources/data/include/read-relative-path.md ================================================ .read {../code.txt} ================================================ FILE: quarkdown-test/src/test/resources/data/include/reference-image.md ================================================ [img]: ../images/picture.png img: ![img] ================================================ FILE: quarkdown-test/src/test/resources/data/include/relative-image.md ================================================ img: ![img](../img/icon.png) ================================================ FILE: quarkdown-test/src/test/resources/data/include/shared-function-usage.md ================================================ ### .hello {world} ================================================ FILE: quarkdown-test/src/test/resources/data/include/stdlib-call.md ================================================ .loremipsum ================================================ FILE: quarkdown-test/src/test/resources/data/include/subdocument-linker.qd ================================================ [1](../subdoc/simple-1.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/include/transitive-include.md ================================================ .function {hello} x: Hello, .x! # Included .hello {Gio} .include {shared-function-usage.md} ================================================ FILE: quarkdown-test/src/test/resources/data/include/url-image.md ================================================ img: ![img](https://example.com/img/icon.png) ================================================ FILE: quarkdown-test/src/test/resources/data/libraries/content.qd ================================================ ## Title Content ================================================ FILE: quarkdown-test/src/test/resources/data/libraries/file-reader.qd ================================================ .csv {csv/people.csv} .csv {.pathtoroot/csv/people.csv} ================================================ FILE: quarkdown-test/src/test/resources/data/libraries/hello.qd ================================================ .function {hellofromlib} x: Hello, *.x*! ================================================ FILE: quarkdown-test/src/test/resources/data/mermaid/class.mmd ================================================ classDiagram class Bank { +name: string +address: string } class Customer { +name: string +id: int } class BankAccount { +id: int +balance: double +deposit(amount: double) +withdraw(amount: double) } class Transaction { +amount: double +date: date +execute() } class Loan { +id: int +amount: double +interestRate: double +approve() } Bank "1" o-- "*" Customer : manages Customer "1" --> "*" BankAccount : owns Customer "1" --> "*" Loan : has BankAccount "1" --> "*" Transaction : records ================================================ FILE: quarkdown-test/src/test/resources/data/search-index/search-index-no-headings-no-metadata.json ================================================ { "entries": [ { "url": "/", "title": null, "description": null, "keywords": [], "content": "1\n2", "headings": [] }, { "url": "/simple-1", "title": null, "description": null, "keywords": [], "content": "Hello 1", "headings": [] }, { "url": "/simple-2", "title": null, "description": null, "keywords": [], "content": "Hello 2", "headings": [] } ] } ================================================ FILE: quarkdown-test/src/test/resources/data/search-index/search-index-no-headings-with-metadata.json ================================================ { "entries": [ { "url": "/", "title": "Test", "description": null, "keywords": [], "content": "1\n2", "headings": [] }, { "url": "/simple-1", "title": "Test", "description": null, "keywords": [], "content": "Hello 1", "headings": [] }, { "url": "/metadata", "title": "Subdocument name", "description": "Subdocument description", "keywords": [ "keyword1", "keyword2" ], "content": "", "headings": [] } ] } ================================================ FILE: quarkdown-test/src/test/resources/data/search-index/search-index-with-headings.json ================================================ { "entries": [ { "url": "/", "title": "Test", "description": null, "keywords": [], "content": "1\n1", "headings": [] }, { "url": "/headings-1", "title": "Test", "description": null, "keywords": [], "content": "A\n\nB\n\nC\n\nD", "headings": [ { "anchor": "a", "text": "A", "level": 1 }, { "anchor": "b", "text": "B", "level": 2 }, { "anchor": "c", "text": "C", "level": 1 }, { "anchor": "d", "text": "D", "level": 3 } ] }, { "url": "/headings-2", "title": "Test", "description": null, "keywords": [], "content": "E\n\nF\n\nG\n\nH", "headings": [ { "anchor": "e", "text": "E", "level": 1 }, { "anchor": "f", "text": "F", "level": 2 }, { "anchor": "g", "text": "G", "level": 1 }, { "anchor": "h", "text": "H", "level": 3 } ] } ] } ================================================ FILE: quarkdown-test/src/test/resources/data/search-index/search-index-with-page-margin.json ================================================ { "entries": [ { "url": "/", "title": "Test", "description": null, "keywords": [], "content": "Heading\n\nText", "headings": [ { "anchor": "heading", "text": "Heading", "level": 1 } ] } ] } ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/circular-1.qd ================================================ [2](circular-2.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/circular-2.qd ================================================ [1](circular-1.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/docs/_nav.qd ================================================ ###! First section - [Page 1](page-1.qd) - [Page 2](page-2.qd) ###! Second section - [Page 3](page-3.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/docs/_setup.qd ================================================ .doclang {en} .docdescription {Common description} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/docs/page-1.qd ================================================ .docname {Page 1} .include {docs} This is page 1. ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/docs/page-2.qd ================================================ .docname {Page 2} .include {docs} This is page 2. ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/docs/page-3.qd ================================================ .docname {Page 3} .include {docs} This is page 3. ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/gateway.qd ================================================ [1](simple-1.qd) [2](simple-2.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/headings-1.qd ================================================ # A ## B # C ### D ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/headings-2.qd ================================================ # E ## F # G ### H ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/include-lib-1.qd ================================================ .include {content} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/include-lib-2.qd ================================================ .include {hello} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/include-stdlib.qd ================================================ .include {../include/stdlib-call.md} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/media-storage.qd ================================================ ![icon](../img/icon.png) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/media.qd ================================================ ![icon](../img/icon.png) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/metadata.qd ================================================ .docname {Subdocument name} .docdescription {Subdocument description} .dockeywords - keyword1 - keyword2 ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/nav-includer.qd ================================================ .include {nav.qd} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/nav.qd ================================================ - [1](simple-1.qd) - [2](simple-2.qd) - [3](nav-includer.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/path-to-root/subdoc/subdoc.qd ================================================ .include {../utils/path-to-root.qd} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/path-to-root/utils/path-to-root.qd ================================================ .pathtoroot granularity:{project} .pathtoroot granularity:{subdocument} ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/recursive.qd ================================================ [1](recursive.qd) ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/simple-1.qd ================================================ Hello 1 ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/simple-2.qd ================================================ Hello 2 ================================================ FILE: quarkdown-test/src/test/resources/data/subdoc/stdlib-call.qd ================================================ .loremipsum ================================================ FILE: scripts/bootstrap.bat ================================================ if defined QD_NPM_PREFIX ( set "NODE_PATH=%QD_NPM_PREFIX%\node_modules" ) if exist "%ProgramFiles%\Google\Chrome\Application\chrome.exe" ( set "BROWSER_CHROME=%ProgramFiles%\Google\Chrome\Application\chrome.exe" ) if exist "%ProgramFiles%\Chromium\Application\chrome.exe" ( set "BROWSER_CHROMIUM=%ProgramFiles%\Chromium\Application\chrome.exe" ) if exist "%ProgramFiles%\Mozilla Firefox\firefox.exe" ( set "BROWSER_FIREFOX=%ProgramFiles%\Mozilla Firefox\firefox.exe" ) if exist "%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" ( set "BROWSER_CHROME=%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" ) if exist "%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe" ( set "BROWSER_FIREFOX=%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe" ) if exist "%ProgramFiles%\Microsoft\Edge\Application\msedge.exe" ( set "BROWSER_EDGE=%ProgramFiles%\Microsoft\Edge\Application\msedge.exe" ) if exist "%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe" ( set "BROWSER_EDGE=%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe" ) ================================================ FILE: scripts/bootstrap.sh ================================================ if [ -n "$QD_NPM_PREFIX" ]; then export NODE_PATH="$QD_NPM_PREFIX/node_modules" fi # Browser detection if [ "$(uname)" = "Darwin" ]; then # macOS if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then export BROWSER_CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" fi if [ -x "/Applications/Chromium.app/Contents/MacOS/Chromium" ]; then export BROWSER_CHROMIUM="/Applications/Chromium.app/Contents/MacOS/Chromium" fi if [ -x "/Applications/Firefox.app/Contents/MacOS/firefox" ]; then export BROWSER_FIREFOX="/Applications/Firefox.app/Contents/MacOS/firefox" fi else # Linux export QD_NO_SANDBOX=true # No Chrome sandbox on Linux if command -v google-chrome > /dev/null; then export BROWSER_CHROME="$(command -v google-chrome)" fi if command -v chromium-browser > /dev/null; then export BROWSER_CHROMIUM="$(command -v chromium-browser)" fi if command -v chromium > /dev/null; then export BROWSER_CHROMIUM="$(command -v chromium)" fi if command -v firefox > /dev/null; then export BROWSER_FIREFOX="$(command -v firefox)" fi fi ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { repositories { gradlePluginPortal() mavenCentral() } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } rootProject.name = "quarkdown" include("quarkdown-core") include("quarkdown-html") include("quarkdown-plaintext") include("quarkdown-cli") include("quarkdown-stdlib") include("quarkdown-test") include("quarkdown-libs") include("quarkdown-server") include("quarkdown-interaction") include("quarkdown-quarkdoc") include("quarkdown-quarkdoc-reader") include("quarkdown-lsp") ================================================ FILE: version.txt ================================================ 1.14.1