diff --git a/docs/03-support/01-faq/questions/_mfa-weitere-informationen.mdx b/docs/03-support/01-faq/questions/_mfa-weitere-informationen.mdx
index db2b101e..2fffee1c 100644
--- a/docs/03-support/01-faq/questions/_mfa-weitere-informationen.mdx
+++ b/docs/03-support/01-faq/questions/_mfa-weitere-informationen.mdx
@@ -1,7 +1,9 @@
#### Wo finde ich weitere Informationen und Anleitungen zur __MFA__?
-
- Eine detaillierte Anleitung von EDUBERN finden Sie hier: [EDUBERN: Anleitung MFA](https://erzbe.sharepoint.com/sites/EDUBERN-Infohub-Hilfsmittel/Lists/Hilfsmittel/Attachments/4/Anleitung%20-%20Einrichten%20MFA%20EDUBERN%20.pdf).
+ Offizielle Anleitungen von EDUBERN finden Sie hier:
+ - [EDUBERN Kurzanleitung Account & MFA](@office-onboarding-short)
+ - [EDUBERN detaillierte Anleitung Account & MFA](@office-onboarding-long)
+ - [EDUBERN Anleitung MFA ohne Handy](@mfa-ohne-handy)
\ No newline at end of file
diff --git a/docs/link-mappings.yaml b/docs/link-mappings.yaml
new file mode 100644
index 00000000..4a593354
--- /dev/null
+++ b/docs/link-mappings.yaml
@@ -0,0 +1,3 @@
+mfa-ohne-handy: "https://erzbe.sharepoint.com/sites/EDUBERN-Infohub-Hilfsmittel/Lists/Hilfsmittel/Attachments/21/Anleitung_MFA_ohne_Handy.pdf"
+office-onboarding-short: "https://erzbe.sharepoint.com/sites/EDUBERN-Infohub-Hilfsmittel/Lists/Hilfsmittel/Attachments/157/Anleitung%20%e2%80%93%20Onboarding%20EDUBERN_BYOD_RESET_D_SHORT.pdf"
+office-onboarding-long: "https://erzbe.sharepoint.com/sites/EDUBERN-Infohub-Hilfsmittel/Lists/Hilfsmittel/Attachments/157/Anleitung%20%e2%80%93%20Onboarding%20EDUBERN%20BYOD%20FED_RESET_D.pdf"
diff --git a/packages/ict/link-mapping/README.mdx b/packages/ict/link-mapping/README.mdx
new file mode 100644
index 00000000..32ed675f
--- /dev/null
+++ b/packages/ict/link-mapping/README.mdx
@@ -0,0 +1,39 @@
+---
+page_id: 979630e9-9272-410b-a7c1-fe40601be7a4
+---
+
+# Link Mapping
+
+Mit `@ict/link-mapping` lassen sich links in einer `.yaml`-Datei festlegen und dann als Markdown-Link verwenden.
+
+```yaml
+foo: https://foo.com
+bar: https://bar.com
+```
+
+```md
+Auf [foo](@foo) sieht man einen Link zu [bar](@bar).
+```
+
+Der MD-Link muss mit einem @ beginnen, damit es der Schlüssel im YAML gesucht wird.
+
+## Vorteile
+Links sind an einem Ort und können leicht überprüft/aktualisiert werden, ggf. auch automatisch.
+
+## Installation
+
+```ts title="siteConfig.ts"
+import linkMappingPlugin from '@ict/link-mapping';
+
+const getSiteConfig: SiteConfigProvider = () => {
+ return {
+ /*...*/
+ remarkPlugins: [
+ /*...*/
+ // highlight-next-line
+ [linkMappingPlugin, { mappingFilePath: './docs/link-mappings.yaml' }]
+ /*...*/
+ ],
+ }
+}
+```
\ No newline at end of file
diff --git a/packages/ict/link-mapping/index.ts b/packages/ict/link-mapping/index.ts
new file mode 100644
index 00000000..4c403ec1
--- /dev/null
+++ b/packages/ict/link-mapping/index.ts
@@ -0,0 +1,66 @@
+import { visit } from 'unist-util-visit';
+import type { Plugin, Transformer } from 'unified';
+import { Root, Text } from 'mdast';
+import * as yaml from 'js-yaml';
+import * as fs from 'fs/promises';
+import path from 'path';
+
+interface OptionsInput {
+ mapping: Record;
+ mappingFilePath?: string;
+}
+
+const links = new Map();
+let lastModifiedTime = 0;
+
+const loadMapping = async (mappingFilePath?: string) => {
+ if (!mappingFilePath) {
+ return;
+ }
+ // get time of last modification of the mapping file
+ const stats = await fs.stat(mappingFilePath).catch((err) => {
+ return { mtimeMs: 0 };
+ });
+ const lastModified = stats.mtimeMs;
+
+ if (lastModified <= lastModifiedTime) {
+ return;
+ }
+ if (lastModified > lastModifiedTime) {
+ lastModifiedTime = lastModified;
+ const fileContent = await fs.readFile(mappingFilePath, 'utf8').catch((err) => {
+ console.error(`Error reading mapping file at ${mappingFilePath}:`, err);
+ return '';
+ });
+ const mapping = yaml.load(fileContent) as Record;
+ // don't clear the links map, instead just update it with new values
+ // links.clear();
+ for (const [key, value] of Object.entries(mapping)) {
+ links.set(key, value);
+ }
+ }
+};
+
+const plugin: Plugin = function plugin(
+ optionsInput = { mapping: {} }
+): Transformer {
+ for (const [key, value] of Object.entries(optionsInput.mapping ?? {})) {
+ links.set(key, value);
+ }
+
+ return async (tree) => {
+ await loadMapping(optionsInput.mappingFilePath);
+ visit(tree, 'link', (node) => {
+ if (!node.url.startsWith('@')) {
+ return;
+ }
+ const translationKey = node.url.slice(1);
+ if (!links.has(translationKey)) {
+ return;
+ }
+ node.url = links.get(translationKey) ?? node.url;
+ });
+ };
+};
+
+export default plugin;
diff --git a/packages/ict/link-mapping/package.json b/packages/ict/link-mapping/package.json
new file mode 100644
index 00000000..cb561fc4
--- /dev/null
+++ b/packages/ict/link-mapping/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@ict/link-mapping",
+ "version": "1.0.0",
+ "main": "index.ts",
+ "types": "index.ts",
+ "dependencies": {
+
+ },
+ "devDependencies": {
+ "vitest": "*",
+ "@docusaurus/module-type-aliases": "*",
+ "@docusaurus/core": "*"
+ },
+ "peerDependencies": {
+ "@tdev/core": "1.0.0",
+ "js-yaml": "*",
+ "@types/js-yaml": "*"
+ }
+}
diff --git a/packages/ict/link-mapping/tests/plugin.test.ts b/packages/ict/link-mapping/tests/plugin.test.ts
new file mode 100644
index 00000000..ef718f8e
--- /dev/null
+++ b/packages/ict/link-mapping/tests/plugin.test.ts
@@ -0,0 +1,38 @@
+import { remark } from 'remark';
+import remarkMdx from 'remark-mdx';
+import remarkDirective from 'remark-directive';
+import { describe, expect, it } from 'vitest';
+
+const process = async (content: string, mapping: Record = {}) => {
+ const { default: plugin } = await import('..');
+ const result = await remark()
+ .use(remarkMdx)
+ .use(remarkDirective)
+ .use(plugin, { mapping })
+ .process(content);
+
+ return result.value;
+};
+
+describe('#link annotation', () => {
+ it("does nothing if there's no link", async () => {
+ const input = `# Heading
+
+Some content
+`;
+ const result = await process(input);
+ expect(result).toBe(input);
+ });
+ it('can map links', async () => {
+ const input = `# Details element example
+ Hello [example](@translation-key) world!
+ `;
+ const result = await process(input, { ['translation-key']: 'https://example.org' });
+ expect(result).toMatchInlineSnapshot(`
+ "# Details element example
+
+ Hello [example](https://example.org) world!
+ "
+ `);
+ });
+});
diff --git a/packages/ict/link-mapping/tsconfig.json b/packages/ict/link-mapping/tsconfig.json
new file mode 100644
index 00000000..ab140871
--- /dev/null
+++ b/packages/ict/link-mapping/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../../tsconfig.package.json"
+}
diff --git a/siteConfig.ts b/siteConfig.ts
index 9e2550a7..70630185 100644
--- a/siteConfig.ts
+++ b/siteConfig.ts
@@ -21,6 +21,7 @@ const ADMONITION_CONFIG = {
};
import dynamicRouter from './src/plugins/plugin-dynamic-routes';
import { type DirectusConfig } from '@ict/directus';
+import linkMappingPlugin from '@ict/link-mapping';
declare module './src/siteConfig/siteConfig' {
export interface TdevConfig {
@@ -154,9 +155,10 @@ const getSiteConfig: SiteConfigProvider = () => {
}
]
] as unknown as PluginOptions[],
- remarkPlugins: recommendedRemarkPlugins.filter(
- (p) => p !== commentPluginConfig
- ) as unknown as PluginOptions[],
+ remarkPlugins: [
+ [linkMappingPlugin, { mappingFilePath: './docs/link-mappings.yaml' }],
+ ...recommendedRemarkPlugins.filter((p) => p !== commentPluginConfig)
+ ] as unknown as PluginOptions[],
apiDocumentProviders: [
require.resolve('@tdev/page-read-check/register'),
require.resolve('@ict/directus/register')