Paragraph-level links, preview tooltips

This commit is contained in:
Adrian Rumpold
2025-04-30 12:04:38 +02:00
parent ea7885eeee
commit 7dd913df7b
25 changed files with 569 additions and 102 deletions

View File

@@ -0,0 +1,64 @@
import { act } from "@testing-library/react";
import type * as ZustandExportedTypes from "zustand";
export * from "zustand";
const { create: actualCreate, createStore: actualCreateStore } =
jest.requireActual<typeof ZustandExportedTypes>("zustand");
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>();
const createUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
const store = actualCreate(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
console.log("zustand create mock");
// to support curried version of create
return typeof stateCreator === "function"
? createUncurried(stateCreator)
: createUncurried;
}) as typeof ZustandExportedTypes.create;
const createStoreUncurried = <T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(
stateCreator: ZustandExportedTypes.StateCreator<T>
) => {
console.log("zustand createStore mock");
// to support curried version of createStore
return typeof stateCreator === "function"
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof ZustandExportedTypes.createStore;
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn();
});
});
});

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@floating-ui/react": "^0.27.8",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"axios": "^1.9.0",
@@ -18,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@jest/globals": "^29.7.0",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -1259,6 +1261,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.8.tgz",
"integrity": "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -8129,6 +8184,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",

View File

@@ -11,6 +11,7 @@
"test": "jest"
},
"dependencies": {
"@floating-ui/react": "^0.27.8",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6",
"axios": "^1.9.0",
@@ -21,6 +22,7 @@
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@jest/globals": "^29.7.0",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",

View File

@@ -11,7 +11,8 @@ import CelexSelector from "./components/CelexSelector/CelexSelector";
import { useTOC } from "./hooks/toc";
function App() {
const { numPanels, addPanel } = useUIStore();
const numPanels = useUIStore((state) => state.numPanels);
const addPanel = useUIStore((state) => state.addPanel);
const { data: toc, isPending, error } = useTOC();
if (isPending) {

View File

@@ -32,10 +32,6 @@ describe("ArticleSelector", () => {
],
};
beforeEach(() => {
jest.clearAllMocks();
});
test("renders a top-level division as an optgroup", () => {
const { getByRole } = render(<ArticleSelector toc={[mockDivision]} />);

View File

@@ -1,6 +1,6 @@
import { Fragment, JSX } from "react";
import type { Division } from "../../lib/types";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
import styles from "./ArticleSelector.module.css";
type ArticleSelectorProps = {
@@ -41,7 +41,8 @@ function renderDivision(div: Division): JSX.Element {
}
function ArticleSelector({ toc }: ArticleSelectorProps) {
const { articleId, setArticleId } = useNavState();
const articleId = useNavStore.use.articleId();
const setArticleId = useNavStore.use.setArticleId();
return (
<select

View File

@@ -1,30 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import { examples } from "../../lib/examples";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
import CelexSelector from "./CelexSelector";
jest.mock("../../store/navStore");
describe("CelexSelector", () => {
const mockSetCelexId = jest.fn();
const mockSetArticleId = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useNavState).mockReturnValue({
celexId: "",
setCelexId: mockSetCelexId,
setArticleId: mockSetArticleId,
});
});
it("renders the dropdown with options", () => {
render(<CelexSelector />);
const { getByLabelText, getAllByRole, getByRole } = render(
<CelexSelector />
);
expect(screen.getByLabelText("Select example:")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(getByLabelText("Select example:")).toBeInTheDocument();
expect(getByRole("combobox")).toBeInTheDocument();
const options = screen.getAllByRole("option");
const options = getAllByRole("option");
expect(options).toHaveLength(examples.length);
for (const i in examples) {
expect(options[i]).toHaveValue(examples[i].id);
@@ -32,28 +20,15 @@ describe("CelexSelector", () => {
}
});
it("calls setCelexId and setArticleId on selection change", () => {
const celexId = "32016R0679";
render(<CelexSelector />);
it("calls setCelexId and setArticleId on selection change", async () => {
const celexId = examples[2].id;
const { getByRole } = render(<CelexSelector />);
fireEvent.change(screen.getByRole("combobox"), {
fireEvent.change(getByRole("combobox"), {
target: { value: celexId },
});
expect(mockSetCelexId).toHaveBeenCalledWith(celexId);
expect(mockSetArticleId).toHaveBeenCalledWith(1);
});
it("sets the correct value in the dropdown based on celexId", () => {
const mockCelexId = "32024R1689";
jest.mocked(useNavState).mockReturnValue({
celexId: mockCelexId,
setCelexId: mockSetCelexId,
setArticleId: mockSetArticleId,
});
render(<CelexSelector />);
expect(screen.getByRole("combobox")).toHaveValue(mockCelexId);
expect(useNavStore.getState().celexId).toEqual(celexId);
expect(useNavStore.getState().articleId).toEqual(1);
});
});

View File

@@ -1,8 +1,10 @@
import { examples } from "../../lib/examples";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
function CelexSelector() {
const { celexId, setCelexId, setArticleId } = useNavState();
const celexId = useNavStore.use.celexId();
const setCelexId = useNavStore.use.setCelexId();
const setArticleId = useNavStore.use.setArticleId();
return (
<div>

View File

@@ -3,7 +3,7 @@ import { fireEvent, render } from "@testing-library/react";
import React from "react";
import { getArticle } from "../../lib/api";
import { Language } from "../../lib/types";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
import useUIStore from "../../store/uiStore";
import Panel from "./Panel";
@@ -39,7 +39,7 @@ describe("Panel Component", () => {
};
beforeEach(() => {
jest.mocked(useNavState).mockReturnValue(mockNavState);
jest.mocked(useNavStore).mockReturnValue(mockNavState);
jest.mocked(useUIStore).mockReturnValue(mockUseUIStore);
});

View File

@@ -5,8 +5,10 @@ import useUIStore from "../../store/uiStore";
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
import { useArticle } from "../../hooks/useArticle";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
import "../../styles/PanelContent.css";
import ParagraphPreview from "../ParagraphPreview/ParagraphPreview";
import { Tooltip, TooltipContent } from "../Tooltip/Tooltip";
import styles from "./Panel.module.css";
type PanelProps = {
@@ -18,9 +20,13 @@ function Panel({ language }: PanelProps) {
const [lang, setLang] = useState(language || Language.ENG);
const articleRef = useRef<HTMLDivElement>(null);
const { articleId, celexId, setArticleId } = useNavState();
const { articleId, celexId, setArticleId } = useNavStore();
const { data, isPending, error } = useArticle(celexId, articleId, lang);
const [hoverArticleId, setHoverArticleId] = useState<number | null>(null);
const [hoverParagraphId, setHoverParagraphId] = useState<number | null>(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
useEffect(() => {
const articleElement = articleRef.current;
if (!articleElement) return;
@@ -32,6 +38,7 @@ function Panel({ language }: PanelProps) {
crossRefs.forEach((link) => {
const target = link.getAttribute("data-target");
const targetId = link.getAttribute("data-id");
const paragraphId = link.getAttribute("data-paragraph-id");
if (target && targetId) {
if (target === "article") {
@@ -41,6 +48,17 @@ function Panel({ language }: PanelProps) {
setArticleId(parseInt(targetId));
return false;
};
if (paragraphId) {
link.onmouseover = () => {
setHoverArticleId(parseInt(targetId));
setHoverParagraphId(parseInt(paragraphId));
setIsTooltipOpen(true);
};
link.onmouseout = () => {
setIsTooltipOpen(false);
};
}
}
} else {
console.warn("No target or ID found for link:", link);
@@ -83,18 +101,15 @@ function Panel({ language }: PanelProps) {
// Cleanup event listeners
return () => {
console.log("Cleaning up event listeners");
// crossRefs.forEach((link) => {
// link.onmouseover = null;
// link.onmouseout = null;
// });
paragraphs.forEach((element) => {
element.removeEventListener("click", handleClick(element));
});
};
}, [
articleRef,
data,
celexId,
selectedParagraphId,
setSelectedParagraphId,
setArticleId,
]);
});
if (isPending) return "Loading...";
if (error) return "An error has occurred: " + error.message;
@@ -105,6 +120,20 @@ function Panel({ language }: PanelProps) {
defaultLang={lang}
onChange={setLang}
></LanguageSwitcher>
<Tooltip
open={isTooltipOpen}
placement="right-start"
onOpenChange={setIsTooltipOpen}
>
<TooltipContent>
<ParagraphPreview
celexId={celexId!}
articleId={hoverArticleId!}
paragraphId={hoverParagraphId!}
lang={lang}
/>
</TooltipContent>
</Tooltip>
<div
ref={articleRef}
lang={lang.substring(0, 2)}

View File

@@ -0,0 +1,34 @@
import { useParagraph } from "../../hooks/useParagraph";
import { Language } from "../../lib/types";
type ParagraphPreviewProps = {
celexId: string;
articleId: number;
paragraphId: number;
lang: Language;
};
function ParagraphPreview({
celexId,
articleId,
paragraphId,
lang,
}: ParagraphPreviewProps) {
const { data, isPending, error } = useParagraph(
celexId,
articleId,
paragraphId,
lang
);
if (isPending) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error loading paragraph: {error.message}</p>;
}
return <div dangerouslySetInnerHTML={{ __html: data }} />;
}
export default ParagraphPreview;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { Division } from "../../lib/types";
import useNavState from "../../store/navStore";
import useNavStore from "../../store/navStore";
import styles from "./TOC.module.css";
type TOCProps = {
@@ -8,7 +8,7 @@ type TOCProps = {
};
function TOC({ toc }: TOCProps) {
const { articleId, setArticleId } = useNavState();
const { articleId, setArticleId } = useNavStore();
function containsArticle(division: Division, articleId: number): boolean {
return division.content.some((c) => {

View File

@@ -0,0 +1,10 @@
.Tooltip {
background-color: #444;
color: white;
font-size: 90%;
padding: 4px 8px;
border-radius: 4px;
box-sizing: border-box;
width: max-content;
max-width: 60ch;
}

View File

@@ -0,0 +1,163 @@
import type { Placement } from "@floating-ui/react";
import {
autoUpdate,
flip,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
} from "@floating-ui/react";
import * as React from "react";
import styles from "./Tooltip.module.css";
interface TooltipOptions {
initialOpen?: boolean;
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function useTooltip({
initialOpen = false,
placement = "top",
open: controlledOpen,
onOpenChange: setControlledOpen,
}: TooltipOptions = {}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(5),
flip({
crossAxis: placement.includes("-"),
fallbackAxisSideDirection: "start",
padding: 5,
}),
shift({ padding: 5 }),
],
});
const context = data.context;
const hover = useHover(context, {
move: false,
enabled: controlledOpen == null,
});
const focus = useFocus(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const interactions = useInteractions([hover, focus, dismiss, role]);
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
}),
[open, setOpen, interactions, data]
);
}
type ContextType = ReturnType<typeof useTooltip> | null;
const TooltipContext = React.createContext<ContextType>(null);
export const useTooltipContext = () => {
const context = React.useContext(TooltipContext);
if (context == null) {
throw new Error("Tooltip components must be wrapped in <Tooltip />");
}
return context;
};
export function Tooltip({
children,
...options
}: { children: React.ReactNode } & TooltipOptions) {
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = useTooltip(options);
return (
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
);
}
export const TooltipTrigger = React.forwardRef<
HTMLElement,
React.HTMLProps<HTMLElement> & { asChild?: boolean }
>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
const context = useTooltipContext();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childrenRef = (children as any).ref;
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
// `asChild` allows the user to pass any element as the anchor
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...children.props,
"data-state": context.open ? "open" : "closed",
})
);
}
return (
<button
ref={ref}
// The user can style the trigger based on the state
data-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
);
});
export const TooltipContent = React.forwardRef<
HTMLDivElement,
React.HTMLProps<HTMLDivElement>
>(function TooltipContent({ style, ...props }, propRef) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!context.open) return null;
return (
<FloatingPortal>
<div
ref={ref}
className={styles.Tooltip}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
/>
</FloatingPortal>
);
});

View File

@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { getToc } from "../lib/api";
import { Language } from "../lib/types";
import useNavState from "../store/navStore";
import useNavStore from "../store/navStore";
export const useTOC = () => {
const celexId = useNavState((state) => state.celexId);
const celexId = useNavStore((state) => state.celexId);
const query = useQuery({
queryKey: ["toc", celexId],
queryFn: () => getToc(celexId!, Language.ENG),

View File

@@ -1,11 +1,15 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import useNavState from "../store/navStore";
import useNavStore from "../store/navStore";
export const useUrlSync = () => {
const navigate = useNavigate();
const { celexId: paramCelexId, articleId: paramArticleId } = useParams();
const { articleId, celexId, setArticleId, setCelexId } = useNavState();
const celexId = useNavStore.use.celexId();
const setCelexId = useNavStore.use.setCelexId();
const articleId = useNavStore.use.articleId();
const setArticleId = useNavStore.use.setArticleId();
// Effect to read from URL when URL changes
useEffect(() => {

View File

@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { getParagraph } from "../lib/api";
import { Language } from "../lib/types";
export const useParagraph = (
celexId: string | null,
articleId: number | null,
paragraphId: number | null,
lang: Language
) => {
return useQuery({
queryKey: ["paragraph", celexId, articleId, paragraphId, lang],
queryFn: () => getParagraph(celexId!, articleId!, paragraphId!, lang),
enabled: !!celexId && !!articleId && !!paragraphId,
});
};

View File

@@ -24,6 +24,21 @@ async function getArticle(
return response.data;
}
async function getParagraph(
celexId: string,
article: number,
paragraph: number,
language: string
): Promise<string> {
console.debug(
`Fetching article ${article} paragraph ${paragraph} for CELEX ID ${celexId} in ${language} language`
);
const response = await axios.get<string>(
`${celexId}/articles/${article}/${paragraph}/${language}`
);
return response.data;
}
async function getArticleIds(celexId: string): Promise<number[]> {
console.debug(`Fetching article list for CELEX ID ${celexId}`);
const response = await axios.get<number[]>(`${celexId}/articles`);
@@ -39,4 +54,4 @@ async function getToc(
return response.data;
}
export { getArticle, getArticleIds, getToc };
export { getArticle, getArticleIds, getParagraph, getToc };

View File

@@ -1,10 +1,10 @@
import App from "../App";
import { useUrlSync } from "../hooks/urlSync";
import useNavState from "../store/navStore";
import useNavStore from "../store/navStore";
function MainView() {
useUrlSync();
const celexId = useNavState((state) => state.celexId);
const celexId = useNavStore((state) => state.celexId);
if (!celexId) {
return <div>Error: No CELEX ID provided</div>;

View File

@@ -1,17 +1,19 @@
import { create } from "zustand";
import { create, StateCreator } from "zustand";
import { createSelectors } from "../util/zustand";
interface NavState {
export type NavStore = {
celexId: string | null;
articleId: number | null;
setCelexId: (celexId: string) => void;
setArticleId: (articleId: number | null) => void;
}
};
const useNavState = create<NavState>((set) => ({
celexId: null,
articleId: null,
const navStoreCreator: StateCreator<NavStore> = (set) => ({
celexId: "",
articleId: 1,
setCelexId: (celexId) => set({ celexId }),
setArticleId: (articleId) => set({ articleId }),
}));
});
export default useNavState;
const useNavStore = createSelectors(create<NavStore>()(navStoreCreator));
export default useNavStore;

View File

@@ -11,11 +11,11 @@ interface UIState {
const useUIStore = create<UIState>((set) => ({
numPanels: 1,
selectedParagraphId: null,
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
removePanel: () =>
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
setSelectedParagraphId: (selectedParagraphId: string | null) =>
set({ selectedParagraphId }),
setSelectedParagraphId: (selectedParagraphId) => set({ selectedParagraphId }),
}));
export default useUIStore;

View File

@@ -0,0 +1,18 @@
import { StoreApi, UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S
) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};

View File

@@ -24,5 +24,5 @@
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
"include": ["src", "./jest.setup.ts"]
"include": ["src", "./jest.setup.ts", "__mocks__"]
}

View File

@@ -2,7 +2,7 @@ import html
import re
import warnings
from dataclasses import dataclass
from typing import Optional, Union
from typing import Literal, Optional, Union
import lxml.etree
from lxml import etree as ET
@@ -29,9 +29,10 @@ def text_content(el: lxml.etree.Element) -> str:
@dataclass
class CrossReference:
id: str
target: Literal["article", "annex"]
text: str
target: str
id: str
paragraph: int | None = None
def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossReference]:
@@ -69,8 +70,8 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
# Also, match only at word boundaries to prevent partial matches
parts = PATTERN_PARTS[language]
patterns = {
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+){parts["exclusion"]}\b",
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+){parts["exclusion"]}\b",
"article": rf"\b{parts["article"]}\s+(?P<art_num>\d+)(?:[(](?P<parag_num>\d+)[)])?(?:{parts["exclusion"]})",
"annex": rf"\b{parts["annex"]}\s+(?P<annex_num>[DILMVX]+)(?:{parts["exclusion"]})",
}
for key, pattern in patterns.items():
matches = re.finditer(pattern, text, flags=re.IGNORECASE)
@@ -78,13 +79,54 @@ def extract_xrefs(el: lxml.etree.Element, language: Language) -> list[CrossRefer
crossref_id = (
match.group("art_num") if key == "article" else match.group("annex_num")
)
parag_num = match.groupdict().get("parag_num")
crossref_text = match.group(0)
crossrefs.append(
CrossReference(id=crossref_id, text=crossref_text, target=key)
CrossReference(
target=key,
id=crossref_id,
paragraph=parag_num,
text=crossref_text,
)
)
return crossrefs
def extract_article(doc: ET.ElementBase, article_id: int) -> ET.ElementBase | None:
"""Extract a specific article from a Formex document.
Args:
doc: The XML document to extract from.
article_id: The article number.
Returns:
The extracted article element.
"""
# Use XPath to find the specific article
xpath = f".//ARTICLE[@IDENTIFIER='{article_id:03d}']"
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
def extract_paragraph(
doc: ET.ElementBase, article_id: int, paragraph_id: int
) -> ET.ElementBase | None:
"""Extract a specific paragraph from an article in a Formex document.
Args:
doc: The XML document to extract from.
article_id: The article number.
paragraph_id: The paragraph number.
Returns:
The extracted paragraph element.
"""
# Use XPath to find the specific paragraph
xpath = f".//PARAG[@IDENTIFIER='{article_id:03d}.{paragraph_id:03d}']"
return doc.xpath(xpath)[0] if doc.xpath(xpath) else None
class FormexArticleConverter:
"""Converts Formex XML <ARTICLE> elements to semantic HTML5."""
@@ -136,7 +178,7 @@ class FormexArticleConverter:
# Replace the cross-reference text with a link
text = text.replace(
xref.text,
f'<a href="" data-target="{xref.target}" data-id="{xref.id}" class="cross-ref">{xref.text}</a>',
f'<a href="" data-target="{xref.target}" data-id="{xref.id}" data-paragraph-id="{xref.paragraph or ''}" class="cross-ref">{xref.text}</a>',
)
return text
@@ -418,10 +460,13 @@ class FormexArticleConverter:
article_subtitle = self._convert_btx(sti_art) if sti_art is not None else ""
# Build the header section
header = f'<header><h3 class="article-title">{article_title}</h3>'
if article_subtitle:
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
header += "</header>"
if article_title and article_subtitle:
header = f'<header><h3 class="article-title">{article_title}</h3>'
if article_subtitle:
header += f'<h4 class="article-subtitle">{article_subtitle}</h4>'
header += "</header>"
else:
header = ""
# Process the content based on what's present
content = ""

View File

@@ -2,7 +2,11 @@ import lxml.etree as ET
from fastapi import APIRouter, FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from formex_viewer.formex4 import FormexArticleConverter
from formex_viewer.formex4 import (
FormexArticleConverter,
extract_article,
extract_paragraph,
)
from formex_viewer.main import (
CellarClient,
CellarIdentifier,
@@ -121,21 +125,46 @@ def toc(celex_id: str, language: Language = Language.ENG):
@api_router.get("/{celex_id}/articles/{article_id}/{language}")
def article(celex_id: str, article_id: int, language: Language = Language.ENG):
def article(
celex_id: str,
article_id: int,
language: Language = Language.ENG,
):
"""
Fetch an article from the server.
"""
xml = _get_fmx4_data(celex_id, language)
article = extract_article(xml, article_id=article_id)
article_xpath = "//ARTICLE"
articles = xml.xpath(article_xpath)
for article in articles:
num = article.get("IDENTIFIER").lstrip("0")
if num == str(article_id):
return Response(
FormexArticleConverter(language=language).convert_article(article),
media_type="text/html",
)
if article is None:
return Response(
"Article not found",
status_code=404,
)
return Response(
FormexArticleConverter(language=language).convert_article(article),
media_type="text/html",
)
@api_router.get("/{celex_id}/articles/{article_id}/{parag_id}/{language}")
def paragraph(
celex_id: str,
article_id: int,
parag_id: int,
language: Language = Language.ENG,
):
"""
Fetch a paragraph within an article from the server.
"""
xml = _get_fmx4_data(celex_id, language)
parag = extract_paragraph(xml, article_id=article_id, paragraph_id=parag_id)
return Response(
FormexArticleConverter(language=language).convert_article(parag),
media_type="text/html",
)
app.include_router(api_router, prefix="/api")