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
46 changes: 30 additions & 16 deletions src/app/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import platforms from "../contents/platforms";
import PublicCode, {
defaultItaly,
FIELD_MIN_VERSIONS,
IT_COUNTRY_EXTENSION_VERSION,
LATEST_VERSION,
PublicCodeWithDeprecatedFields,
Expand All @@ -43,7 +44,7 @@
import { collectRemovedKeys, getYaml } from "../lib/utils";
import linter from "../linter";
import publicCodeAdapter from "../publiccode-adapter";
import { toSemVerObject } from "../semver";
import { isVersionAtLeast, toSemVerObject } from "../semver";
import { validator } from "../validator";
import EditorAwards from "./EditorAwards";
import EditorBoolean from "./EditorBoolean";
Expand Down Expand Up @@ -227,10 +228,19 @@
});
const { getValues, handleSubmit, watch, setValue, reset } = methods;

// Show a field only if it exists in the declared publiccode.yml version
// (e.g. `supports` from 0.7.0, `organisation`/`fundedBy` from 0.5.0).
const declaredVersion = watch("publiccodeYmlVersion");
const isFieldAvailable = (field: keyof typeof FIELD_MIN_VERSIONS) =>
isVersionAtLeast(
declaredVersion || LATEST_VERSION,
FIELD_MIN_VERSIONS[field],
);

const checkPubliccodeYmlVersion = useCallback((publicCode: PublicCode) => {
const { publiccodeYmlVersion } = publicCode;
setPubliccodeYmlVersion(publiccodeYmlVersion);
}, []);

Check warning on line 243 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'setPubliccodeYmlVersion'. Either include it or remove the dependency array

Check warning on line 243 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'setPubliccodeYmlVersion'. Either include it or remove the dependency array

const checkItCountryExtensionVersion = useCallback(
(publicCode: PublicCode) => {
Expand All @@ -250,7 +260,7 @@

setShowCountryExtensionVersion(countryExtensionVersionVisible);
},
[],

Check warning on line 263 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'setShowCountryExtensionVersion'. Either include it or remove the dependency array

Check warning on line 263 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'setShowCountryExtensionVersion'. Either include it or remove the dependency array
);

useFormPersist("form-values", {
Expand All @@ -262,7 +272,7 @@
checkPubliccodeYmlVersion(pc);
checkItCountryExtensionVersion(pc);
},
[setLanguages],

Check warning on line 275 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has missing dependencies: 'checkItCountryExtensionVersion' and 'checkPubliccodeYmlVersion'. Either include them or remove the dependency array

Check warning on line 275 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has missing dependencies: 'checkItCountryExtensionVersion' and 'checkPubliccodeYmlVersion'. Either include them or remove the dependency array
),
storage: window?.localStorage, // default window.sessionStorage
exclude: [],
Expand Down Expand Up @@ -489,7 +499,7 @@
yamlLoadEventBus.off("loadRemoteYaml", loadRemoteYamlHandler);
yamlLoadEventBus.off("loadFileYaml", loadFileYamlHandler);
};
}, []);

Check warning on line 502 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has missing dependencies: 'loadFileYamlHandler' and 'loadRemoteYamlHandler'. Either include them or remove the dependency array

Check warning on line 502 in src/app/components/Editor.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has missing dependencies: 'loadFileYamlHandler' and 'loadRemoteYamlHandler'. Either include them or remove the dependency array

return (
<div className="content__editor-wrapper">
Expand Down Expand Up @@ -613,9 +623,11 @@
<span>
<EditorInput<"isBasedOn"> fieldName="isBasedOn" />
</span>
<div>
<EditorFundedBy />
</div>
{isFieldAvailable("fundedBy") && (
<div>
<EditorFundedBy />
</div>
)}
<span>
<EditorInput<"roadmap"> fieldName="roadmap" />
</span>
Expand Down Expand Up @@ -695,18 +707,20 @@
<EditorInput<"logo"> fieldName="logo" />
</span>
</EditorSection>
<EditorSupports />
<EditorSection title={t("editor.sections.organisation")}>
<span>
<EditorInput<"organisation.uri">
fieldName="organisation.uri"
required
/>
</span>
<span>
<EditorInput<"organisation.name"> fieldName="organisation.name" />
</span>
</EditorSection>
{isFieldAvailable("supports") && <EditorSupports />}
{isFieldAvailable("organisation") && (
<EditorSection title={t("editor.sections.organisation")}>
<span>
<EditorInput<"organisation.uri">
fieldName="organisation.uri"
required
/>
</span>
<span>
<EditorInput<"organisation.name"> fieldName="organisation.name" />
</span>
</EditorSection>
)}
<EditorDependsOn />
<EditorSection title={t("editor.sections.localisation")}>
<span>
Expand Down
12 changes: 12 additions & 0 deletions src/app/contents/publiccode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import softwareTypes from "./softwareTypes";
export const LATEST_VERSION = "0.7.0";
export const IT_COUNTRY_EXTENSION_VERSION = "1.0";

// Minimum publiccode.yml version in which each key exists: with an older
// declared version the key is neither shown in the editor nor serialized.
export const FIELD_MIN_VERSIONS = {
supports: "0.7.0",
organisation: "0.5.0",
fundedBy: "0.5.0",
} as const;

// Since 0.5.0 country section keys (IT:) and ISO 3166-1 alpha-2 codes are
// uppercase (lowercase deprecated); older versions mandated lowercase.
export const UPPERCASE_COUNTRY_MIN_VERSION = "0.5.0";

// Known aliases for the `supports` field (publiccode.yml v0.7).
// The stored value is `alias:<name>`; `text` is the human-readable label.
// See https://github.com/italia/publiccode-parser-go (supports_id validator).
Expand Down
55 changes: 55 additions & 0 deletions src/app/country-case.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import YAML from "yaml";
import { publicCodeDummyObjectFactory } from "./contents/publiccode";
import { getYaml, parseYaml } from "./lib/utils";

describe("country section and country codes case by declared version", () => {
const withCountryData = (publiccodeYmlVersion: string) => ({
...publicCodeDummyObjectFactory(),
publiccodeYmlVersion,
intendedAudience: { countries: ["it", "DE"] },
it: {
countryExtensionVersion: "1.0",
riuso: { codiceIPA: "c_h501" },
},
});

it("emits uppercase (IT:, ISO codes) from 0.5.0 onwards", () => {
const yaml = getYaml(withCountryData("0.7.0") as never) ?? "";
const parsed = YAMLparse(yaml);

expect(parsed.IT).toBeDefined();
expect(parsed.it).toBeUndefined();
expect(parsed.intendedAudience.countries).toEqual(["IT", "DE"]);
});

it("emits lowercase (it:, ISO codes) for versions before 0.5.0", () => {
const yaml = getYaml(withCountryData("0.4.0") as never) ?? "";
const parsed = YAMLparse(yaml);

expect(parsed.it).toBeDefined();
expect(parsed.IT).toBeUndefined();
expect(parsed.intendedAudience.countries).toEqual(["it", "de"]);
});

it("round-trips an old lowercase file into the internal representation", () => {
const imported = parseYaml(`
publiccodeYmlVersion: "0.4.0"
it:
riuso:
codiceIPA: c_h501
intendedAudience:
countries:
- it
`);

expect(imported?.it?.riuso?.codiceIPA).toBe("c_h501");
// internal representation is always uppercase
expect(imported?.intendedAudience?.countries).toEqual(["IT"]);
});
});

// Parse raw YAML without the internal-representation normalization done by
// parseYaml, to assert on the actual serialized key case.
function YAMLparse(yaml: string) {
return YAML.parse(yaml);
}
45 changes: 31 additions & 14 deletions src/app/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,68 @@
import { useEffect, useState } from "react";
import YAML from "yaml";
import PublicCode from "../contents/publiccode";
import PublicCode, {
LATEST_VERSION,
UPPERCASE_COUNTRY_MIN_VERSION,
} from "../contents/publiccode";
import linter from "../linter";
import { isVersionAtLeast } from "../semver";

/**
* Converts country codes in intendedAudience to uppercase (ISO 3166-1 alpha-2 standard)
* Converts country codes in intendedAudience to the case mandated by the
* declared publiccode.yml version (uppercase since 0.5.0, lowercase before)
*/
function convertCountriesToUppercase(
intendedAudience?: PublicCode["intendedAudience"]
function convertCountriesCase(
intendedAudience: PublicCode["intendedAudience"],
toUppercase: boolean
): PublicCode["intendedAudience"] {
if (!intendedAudience) {
return intendedAudience;
}

const convertCase = (code: unknown) =>
typeof code === "string"
? toUppercase
? code.toUpperCase()
: code.toLowerCase()
: code;

const converted = { ...intendedAudience };

if (Array.isArray(converted.countries)) {
converted.countries = converted.countries.map((code) =>
typeof code === "string" ? code.toUpperCase() : code
);
converted.countries = converted.countries.map(convertCase) as string[];
}

if (Array.isArray(converted.unsupportedCountries)) {
converted.unsupportedCountries = converted.unsupportedCountries.map(
(code) => (typeof code === "string" ? code.toUpperCase() : code)
);
convertCase
) as string[];
}

return converted;
}

/**
* Converts the data object to use uppercase country section keys (e.g., "it" -> "IT")
* and uppercase country codes in intendedAudience
* Converts the data object to the country-code case mandated by the declared
* publiccode.yml version: uppercase section keys (e.g., "it" -> "IT") and
* country codes since 0.5.0, lowercase for older versions
*/
function convertForYamlOutput(data: PublicCode): Record<string, unknown> {
const linted = linter(data);
const converted: Record<string, unknown> = { ...linted };
const toUppercase = isVersionAtLeast(
linted.publiccodeYmlVersion || LATEST_VERSION,
UPPERCASE_COUNTRY_MIN_VERSION
);

if ("it" in converted && converted.it) {
if ("it" in converted && converted.it && toUppercase) {
converted.IT = converted.it;
delete converted.it;
}

if (linted.intendedAudience) {
converted.intendedAudience = convertCountriesToUppercase(
linted.intendedAudience
converted.intendedAudience = convertCountriesCase(
linted.intendedAudience,
toUppercase
);
}

Expand Down
27 changes: 20 additions & 7 deletions src/app/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import PublicCode, {
defaultPiattaforme,
defaultRiuso,
defaultSupport,
FIELD_MIN_VERSIONS,
LATEST_VERSION,
} from "../contents/publiccode";
import { isVersionAtLeast } from "../semver";
import { removeEmpty } from "./remove-empty";

function validateCategories(categoriesArray: string[]): string[] {
Expand Down Expand Up @@ -85,6 +88,12 @@ export default function linter({
!dependency.version &&
dependency.optional === undefined);

const hasField = (field: keyof typeof FIELD_MIN_VERSIONS) =>
isVersionAtLeast(
publiccodeYmlVersion || LATEST_VERSION,
FIELD_MIN_VERSIONS[field],
);

const sortedPC: PublicCode = {
publiccodeYmlVersion,
name,
Expand All @@ -99,17 +108,21 @@ export default function linter({
categories: categories
? (validateCategories(categories) as (typeof categories)[number][])
: undefined,
organisation,
fundedBy: fundedBy
?.filter((fo) => !isEmptyFundingOrg(fo))
.map((fo) => sortAs(defaultFundingOrganisation, fo)),
organisation: hasField("organisation") ? organisation : undefined,
fundedBy: hasField("fundedBy")
? fundedBy
?.filter((fo) => !isEmptyFundingOrg(fo))
.map((fo) => sortAs(defaultFundingOrganisation, fo))
: undefined,
usedBy: clone(usedBy),
roadmap,
developmentStatus,
softwareType,
supports: supports
?.filter((s) => s?.id != null && s.id.trim() !== "")
.map((s) => sortAs(defaultSupport, s)),
supports: hasField("supports")
? supports
?.filter((s) => s?.id != null && s.id.trim() !== "")
.map((s) => sortAs(defaultSupport, s))
: undefined,
intendedAudience: intendedAudience
? sortAs(defaultIntendedAudience, intendedAudience)
: undefined,
Expand Down
13 changes: 12 additions & 1 deletion src/app/semver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isMinorThanLatest, toSemVerObject } from "./semver";
import { isMinorThanLatest, isVersionAtLeast, toSemVerObject } from "./semver";

describe("semver test", () => {
it("should run", () => {
Expand Down Expand Up @@ -40,4 +40,15 @@ describe("semver test", () => {
expect(actual05).toBeTruthy();
expect(actual07).toBeFalsy();
});

it("isVersionAtLeast compares versions component-wise", () => {
expect(isVersionAtLeast("0.7.0", "0.7.0")).toBe(true);
expect(isVersionAtLeast("0.7.1", "0.7.0")).toBe(true);
expect(isVersionAtLeast("1.0.0", "0.7.0")).toBe(true);
expect(isVersionAtLeast("1.0.0", "0.5.0")).toBe(true);
expect(isVersionAtLeast("0.7", "0.7.0")).toBe(true);
expect(isVersionAtLeast("0.5.0", "0.7.0")).toBe(false);
expect(isVersionAtLeast("0.6.9", "0.7.0")).toBe(false);
expect(isVersionAtLeast("0.2", "0.5.0")).toBe(false);
});
});
13 changes: 13 additions & 0 deletions src/app/semver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ export function toSemVerObject(versionString: string) {
} satisfies SemVerObject;
}

export const isVersionAtLeast = (version: string, min: string) => {
const v = toSemVerObject(version);
const m = toSemVerObject(min);

if (+v.major !== +m.major) {
return +v.major > +m.major;
}
if (+v.minor !== +m.minor) {
return +v.minor > +m.minor;
}
return +v.patch >= +m.patch;
};

export const isMinorThanLatest = (semver: SemVerObject) => {
const latest = toSemVerObject(LATEST_VERSION);

Expand Down
43 changes: 43 additions & 0 deletions src/app/supports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,47 @@ supports:

expect(linter(allEmpty).supports).toBeUndefined();
});

it("drops `supports` when the declared version is older than 0.7.0", () => {
const pc = {
...publicCodeDummyObjectFactory(),
publiccodeYmlVersion: "0.5.0",
supports: [{ id: "alias:gdpr" }],
organisation: { uri: "https://example.org" },
fundedBy: [{ name: "ACME" }],
};

const linted = linter(pc as never);

expect(linted.supports).toBeUndefined();
// organisation and fundedBy exist since 0.5.0, so they must survive
expect(linted.organisation).toEqual({ uri: "https://example.org" });
expect(linted.fundedBy).toEqual([{ name: "ACME", uri: undefined }]);
});

it("drops `organisation` and `fundedBy` when the declared version is older than 0.5.0", () => {
const pc = {
...publicCodeDummyObjectFactory(),
publiccodeYmlVersion: "0.4.0",
supports: [{ id: "alias:gdpr" }],
organisation: { uri: "https://example.org" },
fundedBy: [{ name: "ACME" }],
};

const linted = linter(pc as never);

expect(linted.supports).toBeUndefined();
expect(linted.organisation).toBeUndefined();
expect(linted.fundedBy).toBeUndefined();
});

it("keeps version-gated fields at the exact minimum version", () => {
const pc = {
...publicCodeDummyObjectFactory(),
publiccodeYmlVersion: "0.7.0",
supports: [{ id: "alias:gdpr" }],
};

expect(linter(pc as never).supports).toEqual([{ id: "alias:gdpr" }]);
});
});
Loading