Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
"codeTheme": "light"
},
"pages": [
{
"src": "userGuide/customFileTypes/sampleJson.md",
"fileExtension": ".json",
"searchable": "no"
},
{
"src": "userGuide/customFileTypes/sampleTxt.md",
"fileExtension": ".txt",
"searchable": "no"
},
{
"glob": ["*.md", "userGuide/*.md", "userGuide/components/*.md", "devGuide/*.md", "devGuide/*/*.md"]
},
Expand Down
51 changes: 51 additions & 0 deletions docs/userGuide/addingPages.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,56 @@ in your `site.json` file. See the [`pagesExclude` attribute](siteJsonFile.html#p

</modal>

## Generating Custom File Types

You can also use MarkBind to generate non-HTML files, such as `.json` or `.txt`, from your source `.md` files. This is useful for generating dynamic configuration files or other text-based assets that can benefit from MarkBind's variable system.

To do this, specify the `fileExtension` property for the page configuration in your `site.json`, as explained [here](siteJsonFile.html#pages).

<div class="indented">

{{ icon_example }} Generating a `sampleJson.json` from `sampleJson.md`:

**site.json:**
```json
{
"pages": [
{
"src": "userGuide/customFileTypes/sampleJson.md",
"fileExtension": ".json",
"searchable": "no"
}
]
}
```
**sampleJson.md:**

{% raw %}
```md
{
"title": "globalVariables",
"description": "This file is served as a .json file, but is sourced from a .md file in the source code.",
"baseUrl": "{{baseUrl}}",
"timestamp": "{{timestamp}}",
"MarkBind": "{{MarkBind}}"
}
```
{% endraw %}


**Examples of generated custom files:**
* [sampleJson]({{baseUrl}}/userGuide/customFileTypes/sampleJson.json){no-validation} - A JSON file generated from Markdown, utilizing default global nunjucks variables available in MarkBind sites.
* [sampleTxt]({{baseUrl}}/userGuide/customFileTypes/sampleTxt.txt){no-validation} - A plain text file generated from Markdown, also utilizing global variables.


</div>

**Key Points:**
* **Nunjucks Variables**: You can use Nunjucks global variables (like `baseUrl`, `timestamp`) and site variables within these files, just like in your HTML pages.
* **No Frontmatter or Scripts**: Unlike standard MarkBind pages, custom file types **do not support frontmatter or scripts**. Frontmatter and script tags will be treated as plain text if included.
* **Search**: By default, these files are searchable if `enableSearch` is true for the site. You can disable this by setting `"searchable": "no"` (or `false`) in the page config.
* **Intra-site validation**: If you are linking such files in other pages in your site, you can use the `{no-validation}` tag, e.g. `[sampleTxt]({{baseUrl}}/userGuide/customFileTypes/sampleTxt.txt){no-validation}` to disable intra-site validation warnings for the link, as MarkBind currently does not support intra-site validation for custom file type links.


{% from "njk/common.njk" import previous_next %}
{{ previous_next('authoringContents', 'markBindSyntaxOverview') }}
7 changes: 7 additions & 0 deletions docs/userGuide/customFileTypes/sampleJson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"title": "globalVariables",
"description": "This file is served as a .json file, but is sourced from a .md file in the source code.",
"baseUrl": "{{baseUrl}}",
"timestamp": "{{timestamp}}",
"MarkBind": "{{MarkBind}}"
}
8 changes: 8 additions & 0 deletions docs/userGuide/customFileTypes/sampleTxt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
This file is served as a .txt file, sourced from a .md file.

The content below demonstrates nunjucks variables usage for custom file types:


{% raw %} {{ baseUrl }} {% endraw %}: "{{ baseUrl }}"
{% raw %} {{ timestamp }} {% endraw %}: "{{ timestamp }}"
{% raw %} {{ MarkBind }} {% endraw %}: ”{{ MarkBind }}“
1 change: 1 addition & 0 deletions docs/userGuide/siteJsonFile.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ _(Optional)_ **The styling options to be applied to the site.** This includes:
* **`searchable`**: Specifies that the page(s) should be excluded from searching. Default: `yes`.
* **`externalScripts`**: An array of external scripts to be referenced on the page. Scripts referenced will be run before the layout script.
* **`frontmatter`**: Specifies properties to add to the frontmatter of a page or glob of pages. Overrides any existing properties if they have the same name, and overrides any frontmatter properties specified in `globalOverride`.
* **`fileExtension`**: A string that specifies the output file extension (e.g., `".json"`, `".txt"`) for the generated file. If not specified, defaults to `".html"`. Note that non-HTML files do not support frontmatter or scripts.

<div id="page-property-overriding">
<box type="warning">
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/Page/PageConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export class PageConfig {
*/
src: string;
title?: string;
/**
* The compiled Nunjucks template for the page wrapper (page.njk).
* This is used to generate the HTML version of the page, handling the global structure
* (html, head, body, scripts) wrapping the processed content.
*/
template: Template;
variableProcessor: VariableProcessor;
addressablePagesSource: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Page/PageVueServerRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Bundle is regenerated by webpack and built pages are re-rendered with the latest
Object.values(pageEntries).forEach(async (pageEntry) => {
const { page, renderFn } = pageEntry;
const renderedVuePageContent = await renderVuePage(renderFn);
page.outputPageHtml(renderedVuePageContent);
page.writeOutputFile(renderedVuePageContent);
});
}

Expand Down
41 changes: 38 additions & 3 deletions packages/core/src/Page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,16 @@ export class Page {
return '';
}

/**
* Generates the page content by processing variables, nodes, frontmatter, and plugins.
*
* If the file extension is not .html, it simply processes variables and writes the content.
*
* For HTML files (usual case), it also builds the page navigation, handles layout,
* collects headings/keywords, and compiles the page into a Vue application for server-side rendering.
* Finally, it writes the rendered HTML to the output file.
* @param externalManager to manage external dependencies and configuration
*/
async generate(externalManager: ExternalManager) {
this.resetState(); // Reset for live reload

Expand All @@ -531,6 +541,19 @@ export class Page {
pluginManager, siteLinkManager, this.pageUserScriptsAndStyles);

let content = variableProcessor.renderWithSiteVariables(this.pageConfig.sourcePath, pageSources);

if (path.extname(this.pageConfig.resultPath) !== '.html') {
const hasFrontmatterLike = /^\s*---\s*[\s\S]*?---/m.test(content);
const hasScriptTagLike = /<script[\s>]/i.test(content);
if (hasFrontmatterLike || hasScriptTagLike) {
logger.warn('Detected frontmatter or <script> tag-like content in non-HTML file '
+ `${this.pageConfig.sourcePath}. These will be treated as plain text. `
+ 'If this was intentional, you can safely ignore this warning.');
}
await this.writeOutputFile(content);
return;
}

content = await nodeProcessor.process(this.pageConfig.sourcePath, content) as string;
this.processFrontmatter(nodeProcessor.frontmatter);
content = pluginManager.postRender(this.frontmatter, content);
Expand Down Expand Up @@ -574,13 +597,25 @@ export class Page {
this.filterIconAssets(content, vueSsrHtml);
if (process.env.TEST_MODE) {
content = `<div id="app">${content}</div>`;
await this.outputPageHtml(content);
await this.writeOutputFile(content);
} else {
await this.outputPageHtml(vueSsrHtml);
await this.writeOutputFile(vueSsrHtml);
}
}

async outputPageHtml(content: string) {
/**
* Writes the rendered content to the output file.
* For non-HTML files, it writes the content directly.
* For HTML files, it renders the content into the page template (page.njk) before writing.
* @param content The processed page content (with variables substituted, etc.)
*/
async writeOutputFile(content: string) {
if (path.extname(this.pageConfig.resultPath) !== '.html') {
await fs.outputFile(this.pageConfig.resultPath, content);
return;
}

// Prepare data for page.njk template
const renderedTemplate = this.pageConfig.template.render(
this.prepareTemplateData(content)); // page.njk

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Site/SiteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SiteConfigPage = {
globExclude?: string,
searchable?: string | boolean,
frontmatter?: FrontMatter,
fileExtension?: string,
};

export type SiteConfigStyle = {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/Site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,23 +91,25 @@ const MARKBIND_LINK_HTML = `<a href='${MARKBIND_WEBSITE_URL}'>MarkBind ${MARKBIN
type PageCreationConfig = {
externalScripts: string[],
frontmatter: FrontMatter,
layout: string,
layout?: string,
pageSrc: string,
searchable: boolean,
faviconUrl?: string,
glob?: string,
globExclude?: string
title?: string,
fileExtension?: string,
};

type AddressablePage = {
frontmatter: FrontMatter,
layout: string,
searchable: string,
frontmatter?: FrontMatter,
layout?: string,
searchable?: string | boolean,
src: string,
externalScripts?: string[],
faviconUrl?: string,
title?: string,
fileExtension?: string,
};

type PageGenerationTask = {
Expand Down Expand Up @@ -280,7 +282,10 @@ export class Site {
*/
createPage(config: PageCreationConfig): Page {
const sourcePath = path.join(this.rootPath, config.pageSrc);
const resultPath = path.join(this.outputPath, fsUtil.setExtension(config.pageSrc, '.html'));
const outputExtension = config.fileExtension || '.html';
const relativePath = fsUtil.ensurePosix(path.relative(this.rootPath, sourcePath));
const outputPath = fsUtil.setExtension(relativePath, outputExtension);
const resultPath = path.join(this.outputPath, outputPath);

const baseAssetsPath = path.posix.join(
this.siteConfig.baseUrl || '/', TEMPLATE_SITE_ASSET_FOLDER_NAME,
Expand Down Expand Up @@ -385,6 +390,7 @@ export class Site {
searchable: page.searchable,
layout: page.layout,
frontmatter: page.frontmatter,
fileExtension: page.fileExtension,
}))) as AddressablePage[];
/*
Add pages collected from globs and merge properties for pages
Expand Down Expand Up @@ -938,9 +944,10 @@ export class Site {
pageSrc: page.src,
title: page.title,
layout: page.layout,
frontmatter: page.frontmatter,
searchable: page.searchable !== 'no',
frontmatter: page.frontmatter || {},
searchable: page.searchable !== 'no' && page.searchable !== false,
externalScripts: page.externalScripts || [],
fileExtension: page.fileExtension,
});
}

Expand Down
71 changes: 71 additions & 0 deletions packages/core/test/unit/Page/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import path from 'path';
import fs from 'fs-extra';
import { Page } from '../../../src/Page';
import { VariableProcessor } from '../../../src/variables/VariableProcessor';

const mockFs = fs as any;

jest.mock('fs');
jest.mock('walk-sync');
jest.mock('../../../src/Page/PageVueServerRenderer');
jest.mock('../../../src/html/NodeProcessor');
jest.mock('../../../src/variables/VariableProcessor');
jest.mock('../../../src/plugins/PluginManager');
jest.mock('../../../src/html/SiteLinkManager');
jest.mock('../../../src/Layout/LayoutManager');
jest.mock('../../../src/Page/PageSources');
jest.mock('../../../src/External/ExternalManager');

describe('Page', () => {
const pageConfig = {
sourcePath: 'test.md',
resultPath: 'test.json',
};
const siteConfig = {
baseUrl: '/',
style: {
codeLineNumbers: false,
},
ignore: [],
intrasiteLinkValidation: {},
plantumlCheck: false,
};

test('generate writes raw content for non-html files', async () => {
mockFs.vol.fromJSON({
'test.md': '{"foo": "bar"}',
});

// Mock the VariableProcessor instance to return a specific render method.
// This is necessary because Page.generate uses VariableProcessor internally.
(VariableProcessor as unknown as jest.Mock).mockImplementation(() => ({
renderWithSiteVariables: jest.fn().mockReturnValue('{"foo": "bar"}'),
}));

// Inject the mocked variableProcessor into the Page configuration.
// The Page constructor assigns this.pageConfig from the passed argument,
// and subsequent methods use this.pageConfig.variableProcessor.
const variableProcessor = {
renderWithSiteVariables: jest.fn().mockReturnValue('{"foo": "bar"}'),
};

const configWithMock = {
...pageConfig,
variableProcessor,
};

const page = new Page(configWithMock as any, siteConfig as any);

const externalManager = {
config: { outputPath: '_site' },
generateDependencies: jest.fn(),
};

await page.generate(externalManager as any);

// Verify fs.outputFile was called with correct content
// Verify file content in mocked filesystem
const content = fs.readFileSync(path.resolve('test.json'), 'utf8');
expect(content).toEqual('{"foo": "bar"}');
});
});
Loading