Compare commits

..

10 Commits

22 changed files with 4039 additions and 503 deletions

8
.gitignore vendored
View File

@@ -300,3 +300,11 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.pnpm-store/ .pnpm-store/
# ruff
.ruff_cache/
# Mac
.DS_Store
.AppleDouble
.LSOverride

View File

@@ -1,13 +1,15 @@
# hatch_build.py # hatch_build.py
import subprocess
import pathlib import pathlib
import shutil import shutil
import subprocess
from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class MarkdownBuildHook(BuildHookInterface): class MarkdownBuildHook(BuildHookInterface):
def initialize(self, version, build_data): def initialize(self, version, build_data):
# 1. Compile the UI exactly once per build # 1. Compile the UI exactly once per build
ui_dir = pathlib.Path(__file__).parent / "ui" ui_dir = pathlib.Path(__file__).parent / "markdown_view_plugin" / "ui"
dist_dir = ui_dir / "dist" dist_dir = ui_dir / "dist"
# Clean any existing dist directory to ensure fresh build # Clean any existing dist directory to ensure fresh build
@@ -15,10 +17,8 @@ class MarkdownBuildHook(BuildHookInterface):
shutil.rmtree(dist_dir) shutil.rmtree(dist_dir)
# Install dependencies and build the UI # Install dependencies and build the UI
subprocess.run([ subprocess.run(["pnpm", "install", "--frozen-lockfile"], cwd=ui_dir, check=True)
"pnpm", "install", "--frozen-lockfile"
], cwd=ui_dir, check=True)
subprocess.run(["pnpm", "run", "build"], cwd=ui_dir, check=True) subprocess.run(["pnpm", "run", "build"], cwd=ui_dir, check=True)
# 2. Force-include the compiled assets in the wheel # 2. Force-include the compiled assets in the wheel
build_data["force_include"][str(dist_dir)] = "ui/dist" build_data["force_include"][str(dist_dir)] = "markdown_view_plugin/ui/dist"

View File

@@ -0,0 +1,3 @@
from markdown_view_plugin.markdown_view_plugin import MarkdownViewPlugin
__all__ = ["MarkdownViewPlugin"]

View File

@@ -1,11 +1,12 @@
# Python backend for the Markdown View Plugin using FastAPI # Python backend for the Markdown View Plugin using FastAPI
import importlib.resources as resources
import anyio # For asynchronous file operations import anyio # For asynchronous file operations
from airflow.plugins_manager import AirflowPlugin
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from airflow.plugins_manager import AirflowPlugin
import importlib.resources as resources
# Use importlib.resources to locate static files and view.md # Use importlib.resources to locate static files and view.md
static_files_dir_ref = resources.files("markdown_view_plugin") / "ui" / "dist" static_files_dir_ref = resources.files("markdown_view_plugin") / "ui" / "dist"
@@ -27,6 +28,7 @@ with resources.as_file(static_files_dir_ref) as static_files_dir:
name="markdown_view_plugin_static", name="markdown_view_plugin_static",
) )
# API endpoint to get markdown content # API endpoint to get markdown content
@markdown_fastapi_app.get("/markdown_view/api/view") @markdown_fastapi_app.get("/markdown_view/api/view")
async def get_markdown_content_api(): async def get_markdown_content_api():
@@ -41,6 +43,7 @@ async def get_markdown_content_api():
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# Endpoint to serve the main index.html of the React app # Endpoint to serve the main index.html of the React app
@markdown_fastapi_app.get("/markdown_view") @markdown_fastapi_app.get("/markdown_view")
async def serve_markdown_ui(): async def serve_markdown_ui():
@@ -53,6 +56,7 @@ async def serve_markdown_ui():
) )
return FileResponse(str(index_html_path)) return FileResponse(str(index_html_path))
# Define the Airflow plugin # Define the Airflow plugin
class MarkdownViewPlugin(AirflowPlugin): class MarkdownViewPlugin(AirflowPlugin):
""" """
@@ -67,18 +71,6 @@ class MarkdownViewPlugin(AirflowPlugin):
{ {
"app": markdown_fastapi_app, "app": markdown_fastapi_app,
"name": "markdown_view_app", # A unique name for this FastAPI app "name": "markdown_view_app", # A unique name for this FastAPI app
"app_mount": "/markdown_view_plugin_mount", "url_prefix": "/plugins", # Required for Airflow 3 FastAPI plugins
} }
] ]
# Example menu link (optional, can also be configured via Airflow UI settings)
menu_links = [
{
"name": "Markdown Viewer",
"href": "/markdown_view_plugin_mount/markdown_view", # This should match the app_mount + @markdown_fastapi_app.get path for the UI
"category": "Plugins",
}
]
# For Airflow to pick up the plugin, the class name must match the filename (snake_case to PascalCase)
# or be explicitly defined in an __init__.py in the plugin\'s root directory.
# Assuming filename is markdown_view_plugin.py, class MarkdownViewPlugin is correct.

Binary file not shown.

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@chakra-ui/icons": "^2.2.4", "@chakra-ui/icons": "^2.2.4",
"@chakra-ui/react": "^2.8.2", "@chakra-ui/react": "^3.17.0",
"@chakra-ui/system": "^2.6.2",
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5", "@emotion/styled": "^11.11.5",
"framer-motion": "^11.0.6", "framer-motion": "^11.0.6",
@@ -29,7 +30,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2", "typescript": "~5.8.3",
"vite": "^5.1.0" "vite": "^5.1.0"
} }
} }

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchMarkdownContent } from './api';
// import { SunIcon, MoonIcon } from '@chakra-ui/icons';
import { useColorMode } from '@chakra-ui/system';
// Custom renderer to mimic Airflow core UI style
const markdownComponents = {
h1: (props: any) => <h1 style={{ fontSize: '2rem', fontWeight: 700, margin: '1.5rem 0 1rem 0', color: 'var(--chakra-colors-gray-800, #2D3748)' }} {...props} />,
h2: (props: any) => <h2 style={{ fontSize: '1.5rem', fontWeight: 600, margin: '1.25rem 0 0.75rem 0', color: 'var(--chakra-colors-gray-700, #4A5568)' }} {...props} />,
h3: (props: any) => <h3 style={{ fontSize: '1.2rem', fontWeight: 600, margin: '1rem 0 0.5rem 0', color: 'var(--chakra-colors-gray-600, #718096)' }} {...props} />,
p: (props: any) => <p style={{ margin: '0.5em 0', lineHeight: 1.7, color: 'var(--chakra-colors-gray-800, #2D3748)' }} {...props} />,
code: (props: any) => {
const { inline, className, children, ...rest } = props;
return !inline ? (
<pre style={{ padding: '1em', borderRadius: '6px', background: '#F7FAFC', boxShadow: '0 1px 2px #e2e8f0', overflowX: 'auto', margin: '0.5em 0' }} {...rest}>
<code className={className}>{children}</code>
</pre>
) : (
<code style={{ background: '#EDF2F7', borderRadius: '4px', padding: '0.2em 0.4em', fontSize: '0.95em' }} {...rest}>{children}</code>
);
},
a: (props: any) => <a style={{ color: '#3182CE', textDecoration: 'underline' }} target="_blank" rel="noopener noreferrer" {...props} />,
ul: (props: any) => <ul style={{ margin: '1em 0', paddingLeft: '1.5em', color: 'var(--chakra-colors-gray-800, #2D3748)' }} {...props} />,
ol: (props: any) => <ol style={{ margin: '1em 0', paddingLeft: '1.5em', color: 'var(--chakra-colors-gray-800, #2D3748)' }} {...props} />,
li: (props: any) => <li style={{ marginBottom: '0.25em' }} {...props} />,
table: (props: any) => <table style={{ width: '100%', borderCollapse: 'collapse', margin: '1em 0', fontSize: '1em' }} {...props} />,
thead: (props: any) => <thead style={{ background: '#F7FAFC' }} {...props} />,
tbody: (props: any) => <tbody {...props} />,
tr: (props: any) => <tr style={{ borderBottom: '1px solid #E2E8F0' }} {...props} />,
th: (props: any) => <th style={{ padding: '0.5em', textAlign: 'left', fontWeight: 700, borderBottom: '2px solid #E2E8F0', background: '#F7FAFC' }} {...props} />,
td: (props: any) => <td style={{ padding: '0.5em', borderBottom: '1px solid #E2E8F0' }} {...props} />,
hr: (props: any) => <hr style={{ margin: '2em 0', border: 0, borderTop: '1px solid #E2E8F0' }} {...props} />,
};
const View: React.FC = () => {
const [markdown, setMarkdown] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const { colorMode, toggleColorMode } = useColorMode(); // Hook for theme toggle
useEffect(() => {
const loadContent = async () => {
try {
const content = await fetchMarkdownContent();
setMarkdown(content);
} catch (err) {
setError((err as Error).message);
}
};
loadContent();
}, []);
if (error) {
return (
<div style={{ maxWidth: '1200px', padding: '2em', color: '#E53E3E' }}>Error loading content: {error}</div>
);
}
if (!markdown && !error) {
return (
<div style={{ maxWidth: '1200px', padding: '2em' }}>Loading content...</div>
);
}
return (
<div style={{ maxWidth: '1200px', padding: '1.5em', minHeight: '100vh', display: 'flex', flexDirection: 'column', background: colorMode === 'dark' ? '#1A202C' : '#F7FAFC', color: colorMode === 'dark' ? '#F7FAFC' : '#2D3748' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2em', flexShrink: 0 }}>
<h1 style={{ fontSize: '2em', margin: 0, fontWeight: 700 }}>Markdown View</h1>
<button
aria-label="Toggle theme"
onClick={toggleColorMode}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.5em', color: colorMode === 'dark' ? '#F7FAFC' : '#2D3748' }}
>
{colorMode === 'light' ? '🌙' : '☀️'}
</button>
</div>
<div style={{ padding: '2em', border: '1px solid #E2E8F0', borderRadius: '12px', boxShadow: '0 2px 8px #E2E8F0', overflowY: 'auto', flexGrow: 1, background: colorMode === 'dark' ? '#2D3748' : '#fff' }}>
<ReactMarkdown components={markdownComponents} remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
</div>
);
};
export default View;

View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App'; // Removed .tsx extension
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,29 @@
// Chakra UI v3 does not export extendTheme. Use a plain object for theme config or remove custom theme for now.
// Basic color mode config
// No config needed for ChakraProvider in v3
// Define some basic colors similar to Airflow's scheme
// This is a simplified version. For full consistency, you'd replicate more from Airflow's core theme.ts
const colors = {
airflow: {
50: '#EBF8FF', // Lightest blue
100: '#BEE3F8',
200: '#90CDF4',
300: '#63B3ED',
400: '#4299E1', // Primary blue
500: '#3182CE',
600: '#2B6CB0',
700: '#2C5282',
800: '#2A4365', // Darkest blue
900: '#1A365D',
},
success: {
500: '#38A169', // Green
},
error: {
500: '#E53E3E', // Red
},
};
// No theme export needed for ChakraProvider in v3

View File

@@ -4,7 +4,8 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: '/markdown_view_plugin_mount/static/markdown_view_plugin/', // Important for Airflow to find assets // The base must match the FastAPI static mount: /<url_prefix>/static/markdown_view_plugin/
base: '/plugins/static/markdown_view_plugin/',
build: { build: {
outDir: 'dist', outDir: 'dist',
rollupOptions: { rollupOptions: {

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "airflow-markdown-view-plugin" name = "markdown-view-plugin"
version = "0.1.0" version = "0.1.13"
description = "Airflow UI plugin to render Markdown content using FastAPI and React." description = "Airflow UI plugin to render Markdown content using FastAPI and React."
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -29,8 +29,6 @@ classifiers = [
license = {text = "MIT"} # Updated to new license expression license = {text = "MIT"} # Updated to new license expression
# license-files = ["LICEN[CS]E*"] # Optional: if you add a LICENSE file # license-files = ["LICEN[CS]E*"] # Optional: if you add a LICENSE file
# Hatch-specific way to specify top-level Python modules
py-modules = ["markdown_view_plugin"]
[project.urls] [project.urls]
GitHub = "https://github.com/abhishekbhakat/airflow-markdown-view-plugin" # Replace with actual URL if it exists GitHub = "https://github.com/abhishekbhakat/airflow-markdown-view-plugin" # Replace with actual URL if it exists
@@ -42,6 +40,7 @@ markdown_view_plugin = "markdown_view_plugin:MarkdownViewPlugin"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"build>=1.2.2", "build>=1.2.2",
"hatch>=1.14.1",
"pre-commit>=4.0.1", "pre-commit>=4.0.1",
"ruff>=0.9.2" "ruff>=0.9.2"
] ]
@@ -55,13 +54,29 @@ build-backend = "hatchling.build"
path = "hatch_build.py" path = "hatch_build.py"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
# Ensure data files are included in the wheel at the root.
# markdown_view_plugin.py will be handled by `project.py-modules`.
# These paths are relative to the pyproject.toml file.
include = [ include = [
"markdown_view_plugin",
"view.md", "view.md",
"README.md", "README.md",
"ui/dist" # This tells Hatch to include the ui/dist directory and its contents ]
exclude = [
"markdown_view_plugin/markdown_view_plugin/ui/*",
"markdown_view_plugin/markdown_view_plugin/ui/**",
"!markdown_view_plugin/markdown_view_plugin/ui/dist",
"!markdown_view_plugin/markdown_view_plugin/ui/dist/**"
]
[tool.hatch.build.targets.sdist]
include = [
"markdown_view_plugin",
"view.md",
"README.md",
]
exclude = [
"markdown_view_plugin/markdown_view_plugin/ui/*",
"markdown_view_plugin/markdown_view_plugin/ui/**",
"!markdown_view_plugin/markdown_view_plugin/ui/dist",
"!markdown_view_plugin/markdown_view_plugin/ui/dist/**"
] ]
[tool.ruff] [tool.ruff]

View File

@@ -1,120 +0,0 @@
import React, { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchMarkdownContent } from './api';
import {
Box,
Heading,
Text,
Code,
Link,
ListItem,
OrderedList,
UnorderedList,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Divider,
Container, // Added for layout
Flex, // Added for layout
IconButton,// Added for theme toggle
useColorMode // Hook for theme toggle
} from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons'; // Icons for theme toggle
// Attempt to mimic Airflow's Chakra UI components styling
const chakraComponents = {
h1: (props: any) => <Heading as="h1" size="xl" my={4} {...props} />,
h2: (props: any) => <Heading as="h2" size="lg" my={3} {...props} />,
h3: (props: any) => <Heading as="h3" size="md" my={2} {...props} />,
p: (props: any) => <Text my={2} {...props} />,
code: (props: any) => {
const { inline, className, children, ...rest } = props;
return !inline ? (
<Box
as="pre"
p={4}
rounded="md"
bg="chakra-body-bg"
boxShadow="sm"
overflowX="auto"
my={2}
>
<Code className={className} bg="transparent" {...rest}>
{children}
</Code>
</Box>
) : (
<Code colorScheme="purple" fontSize="0.9em" {...rest}>{children}</Code>
);
},
a: (props: any) => <Link color="teal.500" isExternal {...props} />,
ul: (props: any) => <UnorderedList stylePosition="inside" my={2} {...props} />,
ol: (props: any) => <OrderedList stylePosition="inside" my={2} {...props} />,
li: (props: any) => <ListItem {...props} />,
table: (props: any) => <Table variant="simple" my={4} {...props} />,
thead: (props: any) => <Thead {...props} />,
tbody: (props: any) => <Tbody {...props} />,
tr: (props: any) => <Tr {...props} />,
th: (props: any) => <Th {...props} />,
td: (props: any) => <Td {...props} />,
hr: (props: any) => <Divider my={4} {...props} />,
};
const View: React.FC = () => {
const [markdown, setMarkdown] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const { colorMode, toggleColorMode } = useColorMode(); // Hook for theme toggle
useEffect(() => {
const loadContent = async () => {
try {
const content = await fetchMarkdownContent();
setMarkdown(content);
} catch (err) {
setError((err as Error).message);
}
};
loadContent();
}, []);
if (error) {
return (
<Container maxW="container.xl" py={10}>
<Box color="red.500">Error loading content: {error}</Box>
</Container>
);
}
if (!markdown && !error) { // Show loading only if no error
return (
<Container maxW="container.xl" py={10}>
<Box>Loading content...</Box>
</Container>
);
}
return (
<Container maxW="container.xl" py={6} height="100%" display="flex" flexDirection="column">
<Flex justifyContent="space-between" alignItems="center" mb={6} flexShrink={0}>
<Heading as="h1" size="lg">Markdown View</Heading>
<IconButton
aria-label="Toggle theme"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
/>
</Flex>
<Box p={5} borderWidth="1px" borderRadius="lg" boxShadow="md" overflowY="auto" flexGrow={1}>
<ReactMarkdown components={chakraComponents} remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</Box>
</Container>
);
};
export default View;

View File

@@ -1,14 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App'; // Removed .tsx extension
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
import theme from './theme'; // Removed .ts extension
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ChakraProvider theme={theme}> {/* Apply the custom theme */}
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<App />
</ChakraProvider>
</React.StrictMode>,
);

View File

@@ -1,71 +0,0 @@
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
// Basic color mode config
const config: ThemeConfig = {
initialColorMode: 'system',
useSystemColorMode: true,
};
// Define some basic colors similar to Airflow's scheme
// This is a simplified version. For full consistency, you'd replicate more from Airflow's core theme.ts
const colors = {
airflow: {
50: '#EBF8FF', // Lightest blue
100: '#BEE3F8',
200: '#90CDF4',
300: '#63B3ED',
400: '#4299E1', // Primary blue
500: '#3182CE',
600: '#2B6CB0',
700: '#2C5282',
800: '#2A4365', // Darkest blue
900: '#1A365D',
},
success: {
500: '#38A169', // Green
},
error: {
500: '#E53E3E', // Red
},
};
const theme = extendTheme({
config,
colors,
styles: {
global: (props: any) => ({
body: {
bg: props.colorMode === 'dark' ? 'gray.800' : 'gray.50',
color: props.colorMode === 'dark' ? 'whiteAlpha.900' : 'gray.800',
height: '100%', // Ensure body takes full height
margin: 0,
},
html: {
height: '100%',
},
'#root': { // Ensure root div also takes full height
height: '100%',
}
}),
},
components: {
Button: {
baseStyle: {
// Example: fontWeight: 'bold',
},
variants: {
solid: (props: any) => ({
bg: props.colorMode === 'dark' ? 'airflow.300' : 'airflow.500',
color: props.colorMode === 'dark' ? 'gray.800' : 'white',
_hover: {
bg: props.colorMode === 'dark' ? 'airflow.400' : 'airflow.600',
}
}),
},
},
// You can add more component-specific style overrides here if needed
// For example, for Markdown components if View.tsx styling needs to be theme-aware
},
});
export default theme;

2896
markdown_view_plugin/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff